TextFieldWithToolBar.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import SwiftUI
  2. import UIKit
  3. public struct TextFieldWithToolBar: View {
  4. @Binding var text: Decimal
  5. var placeholder: String
  6. var textColor: Color
  7. var textAlignment: TextAlignment
  8. var keyboardType: UIKeyboardType
  9. var maxLength: Int?
  10. var isDismissible: Bool
  11. var textFieldDidBeginEditing: (() -> Void)?
  12. var textDidChange: ((Decimal) -> Void)?
  13. var numberFormatter: NumberFormatter
  14. var allowDecimalSeparator: Bool
  15. var showArrows: Bool
  16. var previousTextField: (() -> Void)?
  17. var nextTextField: (() -> Void)?
  18. var initialFocus: Bool
  19. @FocusState private var isFocused: Bool
  20. @State private var localText: String = ""
  21. public init(
  22. text: Binding<Decimal>,
  23. placeholder: String,
  24. textColor: Color = .primary,
  25. textAlignment: TextAlignment = .trailing,
  26. keyboardType: UIKeyboardType = .decimalPad,
  27. maxLength: Int? = nil,
  28. isDismissible: Bool = true,
  29. textFieldDidBeginEditing: (() -> Void)? = nil,
  30. textDidChange: ((Decimal) -> Void)? = nil,
  31. numberFormatter: NumberFormatter,
  32. allowDecimalSeparator: Bool = true,
  33. showArrows: Bool = false,
  34. previousTextField: (() -> Void)? = nil,
  35. nextTextField: (() -> Void)? = nil,
  36. initialFocus: Bool = false
  37. ) {
  38. _text = text
  39. self.placeholder = placeholder
  40. self.textColor = textColor
  41. self.textAlignment = textAlignment
  42. self.keyboardType = keyboardType
  43. self.maxLength = maxLength
  44. self.isDismissible = isDismissible
  45. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  46. self.textDidChange = textDidChange
  47. self.numberFormatter = numberFormatter
  48. self.numberFormatter.numberStyle = .decimal
  49. self.allowDecimalSeparator = allowDecimalSeparator
  50. self.showArrows = showArrows
  51. self.previousTextField = previousTextField
  52. self.nextTextField = nextTextField
  53. self.initialFocus = initialFocus
  54. }
  55. public var body: some View {
  56. TextField(placeholder, text: $localText)
  57. .focused($isFocused)
  58. .multilineTextAlignment(textAlignment)
  59. .foregroundColor(textColor)
  60. .keyboardType(keyboardType)
  61. .toolbar {
  62. if isFocused {
  63. ToolbarItemGroup(placement: .keyboard) {
  64. if showArrows {
  65. Button(action: { previousTextField?() }) {
  66. Image(systemName: "chevron.up")
  67. }
  68. Button(action: { nextTextField?() }) {
  69. Image(systemName: "chevron.down")
  70. }
  71. }
  72. Button(action: {
  73. localText = ""
  74. text = 0
  75. textDidChange?(0)
  76. }) {
  77. Image(systemName: "trash")
  78. }
  79. Spacer()
  80. if isDismissible {
  81. Button(action: { isFocused = false }) {
  82. Image(systemName: "keyboard.chevron.compact.down")
  83. }
  84. }
  85. }
  86. }
  87. }
  88. .onChange(of: isFocused) { _, newValue in
  89. if newValue {
  90. textFieldDidBeginEditing?()
  91. } else {
  92. // Format when losing focus
  93. if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
  94. text = decimal
  95. localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
  96. }
  97. }
  98. }
  99. .onChange(of: localText) { _, newValue in
  100. handleTextChange(newValue)
  101. }
  102. .onChange(of: text) { _, newValue in
  103. if newValue == 0, localText.isEmpty {
  104. // Keep empty state
  105. return
  106. }
  107. let newText = numberFormatter.string(from: newValue as NSNumber) ?? ""
  108. if localText != newText {
  109. localText = newText
  110. }
  111. }
  112. .onAppear {
  113. if text != 0 {
  114. localText = numberFormatter.string(from: text as NSNumber) ?? ""
  115. }
  116. // Set initial focus if requested
  117. isFocused = initialFocus
  118. }
  119. }
  120. private func handleTextChange(_ newValue: String) {
  121. // Handle empty string
  122. if newValue.isEmpty {
  123. text = 0
  124. textDidChange?(0)
  125. return
  126. }
  127. let currentDecimalSeparator = numberFormatter.decimalSeparator ?? "."
  128. // Prevent multiple decimal separators
  129. let decimalSeparatorCount = newValue.filter { String($0) == currentDecimalSeparator }.count
  130. if decimalSeparatorCount > 1 {
  131. // If multiple separators, keep the old value
  132. localText = numberFormatter.string(from: text as NSNumber) ?? ""
  133. return
  134. }
  135. // Replace wrong decimal separator with the correct one
  136. var processedText = newValue
  137. if newValue.contains("."), currentDecimalSeparator != "." {
  138. processedText = newValue.replacingOccurrences(of: ".", with: currentDecimalSeparator)
  139. } else if newValue.contains(","), currentDecimalSeparator != "," {
  140. processedText = newValue.replacingOccurrences(of: ",", with: currentDecimalSeparator)
  141. }
  142. // Handle leading decimal separator
  143. if processedText.hasPrefix(currentDecimalSeparator) {
  144. processedText = "0" + processedText
  145. }
  146. // Update if valid decimal
  147. if let decimal = Decimal(string: processedText, locale: numberFormatter.locale) {
  148. text = decimal
  149. textDidChange?(decimal)
  150. // If the processed text is different from the input, update the field
  151. if processedText != newValue {
  152. localText = processedText
  153. }
  154. } else {
  155. // If not a valid decimal, keep the old value
  156. localText = numberFormatter.string(from: text as NSNumber) ?? ""
  157. }
  158. }
  159. }
  160. extension UITextField {
  161. func moveCursorToEnd() {
  162. dispatchPrecondition(condition: .onQueue(.main))
  163. let newPosition = endOfDocument
  164. selectedTextRange = textRange(from: newPosition, to: newPosition)
  165. }
  166. }
  167. extension UIApplication {
  168. @objc func endEditing() {
  169. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  170. }
  171. }
  172. public struct TextFieldWithToolBarString: UIViewRepresentable {
  173. @Binding var text: String
  174. var placeholder: String
  175. var textAlignment: NSTextAlignment = .right
  176. var keyboardType: UIKeyboardType = .default
  177. var autocapitalizationType: UITextAutocapitalizationType = .none
  178. var autocorrectionType: UITextAutocorrectionType = .no
  179. var shouldBecomeFirstResponder: Bool = false
  180. var maxLength: Int? = nil
  181. var isDismissible: Bool = true
  182. public func makeUIView(context: Context) -> UITextField {
  183. let textField = UITextField()
  184. context.coordinator.textField = textField
  185. textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
  186. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  187. textField.delegate = context.coordinator
  188. textField.text = text
  189. textField.placeholder = placeholder
  190. textField.textAlignment = textAlignment
  191. textField.keyboardType = keyboardType
  192. textField.autocapitalizationType = autocapitalizationType
  193. textField.autocorrectionType = autocorrectionType
  194. textField.adjustsFontSizeToFitWidth = true
  195. return textField
  196. }
  197. /// Creates and configures a toolbar for the text field with clear and dismiss buttons.
  198. /// - Parameters:
  199. /// - textField: The text field for which the toolbar is being created.
  200. /// - context: The SwiftUI context that contains the coordinator for handling button actions.
  201. /// - Returns: A configured UIToolbar with clear and dismiss buttons.
  202. private func createToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  203. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  204. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  205. let doneButton = UIBarButtonItem(
  206. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  207. style: .done,
  208. target: textField,
  209. action: #selector(UITextField.resignFirstResponder)
  210. )
  211. let clearButton = UIBarButtonItem(
  212. image: UIImage(systemName: "trash"),
  213. style: .plain,
  214. target: context.coordinator,
  215. action: #selector(Coordinator.clearText)
  216. )
  217. toolbar.items = [clearButton, flexibleSpace, doneButton]
  218. toolbar.sizeToFit()
  219. return toolbar
  220. }
  221. public func updateUIView(_ textField: UITextField, context: Context) {
  222. if textField.text != text {
  223. textField.text = text
  224. }
  225. textField.textAlignment = textAlignment
  226. textField.keyboardType = keyboardType
  227. textField.autocapitalizationType = autocapitalizationType
  228. textField.autocorrectionType = autocorrectionType
  229. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  230. if textField.window != nil, textField.becomeFirstResponder() {
  231. context.coordinator.didBecomeFirstResponder = true
  232. }
  233. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  234. context.coordinator.didBecomeFirstResponder = false
  235. }
  236. }
  237. public func makeCoordinator() -> Coordinator {
  238. Coordinator(self, maxLength: maxLength)
  239. }
  240. public final class Coordinator: NSObject {
  241. var parent: TextFieldWithToolBarString
  242. var textField: UITextField?
  243. let maxLength: Int?
  244. var didBecomeFirstResponder = false
  245. init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
  246. self.parent = parent
  247. self.maxLength = maxLength
  248. }
  249. @objc fileprivate func clearText() {
  250. parent.text = ""
  251. textField?.text = ""
  252. }
  253. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  254. DispatchQueue.main.async {
  255. textField.moveCursorToEnd()
  256. }
  257. }
  258. }
  259. }
  260. extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
  261. public func textField(
  262. _ textField: UITextField,
  263. shouldChangeCharactersIn range: NSRange,
  264. replacementString string: String
  265. ) -> Bool {
  266. guard let currentText = textField.text as NSString? else {
  267. return false
  268. }
  269. // Calculate the new text length
  270. let newLength = currentText.length + string.count - range.length
  271. // If there's a maxLength, ensure the new length is within the limit
  272. if let maxLength = parent.maxLength, newLength > maxLength {
  273. return false
  274. }
  275. // Attempt to replace characters in range with the replacement string
  276. let newText = currentText.replacingCharacters(in: range, with: string)
  277. // Update the binding text state
  278. DispatchQueue.main.async {
  279. self.parent.text = newText
  280. }
  281. return true
  282. }
  283. }