TextFieldWithToolBar.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import SwiftUI
  2. import UIKit
  3. public struct TextFieldWithToolBar: UIViewRepresentable {
  4. @Binding var text: Decimal
  5. var placeholder: String
  6. var textColor: UIColor
  7. var textAlignment: NSTextAlignment
  8. var keyboardType: UIKeyboardType
  9. var autocapitalizationType: UITextAutocapitalizationType
  10. var autocorrectionType: UITextAutocorrectionType
  11. var shouldBecomeFirstResponder: Bool
  12. var maxLength: Int?
  13. var isDismissible: Bool
  14. var textFieldDidBeginEditing: (() -> Void)?
  15. var numberFormatter: NumberFormatter
  16. var allowDecimalSeparator: Bool
  17. var showArrows: Bool
  18. var previousTextField: (() -> Void)?
  19. var nextTextField: (() -> Void)?
  20. public init(
  21. text: Binding<Decimal>,
  22. placeholder: String,
  23. textColor: UIColor = .label,
  24. textAlignment: NSTextAlignment = .right,
  25. keyboardType: UIKeyboardType = .decimalPad,
  26. autocapitalizationType: UITextAutocapitalizationType = .none,
  27. autocorrectionType: UITextAutocorrectionType = .no,
  28. shouldBecomeFirstResponder: Bool = false,
  29. maxLength: Int? = nil,
  30. isDismissible: Bool = true,
  31. textFieldDidBeginEditing: (() -> Void)? = nil,
  32. numberFormatter: NumberFormatter,
  33. allowDecimalSeparator: Bool = true,
  34. showArrows: Bool = false,
  35. previousTextField: (() -> Void)? = nil,
  36. nextTextField: (() -> Void)? = nil
  37. ) {
  38. _text = text
  39. self.placeholder = placeholder
  40. self.textColor = textColor
  41. self.textAlignment = textAlignment
  42. self.keyboardType = keyboardType
  43. self.autocapitalizationType = autocapitalizationType
  44. self.autocorrectionType = autocorrectionType
  45. self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
  46. self.maxLength = maxLength
  47. self.isDismissible = isDismissible
  48. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  49. self.numberFormatter = numberFormatter
  50. self.numberFormatter.numberStyle = .decimal
  51. self.allowDecimalSeparator = allowDecimalSeparator
  52. self.showArrows = showArrows
  53. self.previousTextField = previousTextField
  54. self.nextTextField = nextTextField
  55. }
  56. public func makeUIView(context: Context) -> UITextField {
  57. let textField = UITextField()
  58. context.coordinator.textField = textField
  59. textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
  60. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  61. textField.delegate = context.coordinator
  62. if text == 0 { /// show no value initially, i.e. empty String
  63. textField.text = ""
  64. } else {
  65. textField.text = numberFormatter.string(for: text)
  66. }
  67. textField.placeholder = placeholder
  68. return textField
  69. }
  70. /// Creates and configures a toolbar for the text field with navigation and action buttons.
  71. /// - Parameters:
  72. /// - _: The text field for which the toolbar is being created (unused parameter).
  73. /// - context: The SwiftUI context that contains the coordinator for handling button actions.
  74. /// - Returns: A configured UIToolbar with appropriate buttons based on the view's configuration.
  75. private func createToolbar(for _: UITextField, context: Context) -> UIToolbar {
  76. let toolbar = UIToolbar()
  77. var items: [UIBarButtonItem] = []
  78. // Add navigation arrows if enabled
  79. if showArrows {
  80. // Add clear button
  81. items.append(
  82. UIBarButtonItem(
  83. image: UIImage(systemName: "trash"),
  84. style: .plain,
  85. target: context.coordinator,
  86. action: #selector(Coordinator.clearText)
  87. )
  88. )
  89. if previousTextField != nil {
  90. let previousButton = UIBarButtonItem(
  91. image: UIImage(systemName: "chevron.up"),
  92. style: .plain,
  93. target: context.coordinator,
  94. action: #selector(Coordinator.previousTextField)
  95. )
  96. items.append(previousButton)
  97. }
  98. if nextTextField != nil {
  99. let nextButton = UIBarButtonItem(
  100. image: UIImage(systemName: "chevron.down"),
  101. style: .plain,
  102. target: context.coordinator,
  103. action: #selector(Coordinator.nextTextField)
  104. )
  105. items.append(nextButton)
  106. }
  107. }
  108. // Add flexible space
  109. items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
  110. // Add done button
  111. items.append(
  112. UIBarButtonItem(
  113. barButtonSystemItem: .done,
  114. target: UIApplication.shared,
  115. action: #selector(UIApplication.endEditing)
  116. )
  117. )
  118. toolbar.items = items
  119. toolbar.sizeToFit()
  120. return toolbar
  121. }
  122. public func updateUIView(_ textField: UITextField, context: Context) {
  123. if text != 0 {
  124. let newText = numberFormatter.string(for: text) ?? ""
  125. if textField.text != newText {
  126. textField.text = newText
  127. }
  128. }
  129. textField.textColor = textColor
  130. textField.textAlignment = textAlignment
  131. textField.keyboardType = keyboardType
  132. textField.autocapitalizationType = autocapitalizationType
  133. textField.autocorrectionType = autocorrectionType
  134. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  135. if textField.window != nil, textField.becomeFirstResponder() {
  136. context.coordinator.didBecomeFirstResponder = true
  137. }
  138. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  139. context.coordinator.didBecomeFirstResponder = false
  140. }
  141. }
  142. public func makeCoordinator() -> Coordinator {
  143. Coordinator(self, maxLength: maxLength)
  144. }
  145. public final class Coordinator: NSObject {
  146. var parent: TextFieldWithToolBar
  147. var textField: UITextField?
  148. let maxLength: Int?
  149. var didBecomeFirstResponder = false
  150. let decimalFormatter: NumberFormatter
  151. init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
  152. self.parent = parent
  153. self.maxLength = maxLength
  154. decimalFormatter = NumberFormatter()
  155. decimalFormatter.locale = Locale.current
  156. decimalFormatter.numberStyle = .decimal
  157. }
  158. @objc fileprivate func clearText() {
  159. parent.text = 0
  160. textField?.text = ""
  161. }
  162. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  163. DispatchQueue.main.async {
  164. textField.moveCursorToEnd()
  165. }
  166. }
  167. @objc fileprivate func previousTextField() {
  168. parent.previousTextField?()
  169. }
  170. @objc fileprivate func nextTextField() {
  171. parent.nextTextField?()
  172. }
  173. // Helper method to calculate the number of decimal places in a string
  174. fileprivate func calculateDecimalPlaces(in string: String) -> Int {
  175. guard let decimalSeparator = decimalFormatter.decimalSeparator else { return 0 }
  176. if let range = string.range(of: decimalSeparator) {
  177. let decimalPart = string[range.upperBound...]
  178. return decimalPart.count
  179. }
  180. return 0
  181. }
  182. // Helper method to check if the cursor is after the decimal separator
  183. fileprivate func isCursorAfterDecimal(in textField: UITextField, range: NSRange) -> Bool {
  184. guard let text = textField.text, let decimalSeparator = decimalFormatter.decimalSeparator else { return false }
  185. if let decimalSeparatorRange = text.range(of: decimalSeparator) {
  186. let decimalSeparatorPosition = text.distance(from: text.startIndex, to: decimalSeparatorRange.lowerBound)
  187. return range.location > decimalSeparatorPosition
  188. }
  189. return false
  190. }
  191. }
  192. }
  193. extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
  194. public func textFieldDidEndEditing(_ textField: UITextField) {
  195. if let text = textField.text,
  196. let decimal = Decimal(string: text, locale: parent.numberFormatter.locale)
  197. {
  198. // Format the number properly when editing ends
  199. textField.text = parent.numberFormatter.string(from: decimal as NSNumber)
  200. parent.text = decimal
  201. }
  202. }
  203. public func textField(
  204. _ textField: UITextField,
  205. shouldChangeCharactersIn range: NSRange,
  206. replacementString string: String
  207. ) -> Bool {
  208. // Check if the input is a number or the decimal separator
  209. let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
  210. // Get the current locale's decimal separator
  211. let currentDecimalSeparator = parent.numberFormatter.decimalSeparator ?? "."
  212. // Check if input is a decimal separator (either . or ,)
  213. let isInputDecimalSeparator = string == "." || string == ","
  214. // Only allow the decimal separator configured in the locale
  215. if isInputDecimalSeparator {
  216. // If it's not the correct decimal separator for this locale, reject it
  217. if string != currentDecimalSeparator {
  218. return false
  219. }
  220. // Check if the field already contains a decimal separator
  221. if textField.text?.contains(currentDecimalSeparator) == true {
  222. return false
  223. }
  224. }
  225. // Only proceed if the input is a valid number or the correct decimal separator
  226. if isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator),
  227. let currentText = textField.text as NSString?
  228. {
  229. // Calculate the new text length
  230. let newLength = currentText.length + string.count - range.length
  231. // Check max length if specified
  232. if let maxLength = parent.maxLength, newLength > maxLength {
  233. return false
  234. }
  235. // Create the new text string
  236. let newText = currentText.replacingCharacters(in: range, with: string)
  237. // If text starts with decimal separator, add leading zero
  238. if newText.hasPrefix(currentDecimalSeparator) {
  239. textField.text = "0" + newText
  240. parent.text = Decimal(string: textField.text ?? "0") ?? 0
  241. return false
  242. }
  243. // Update the binding
  244. if let decimal = Decimal(string: newText, locale: parent.numberFormatter.locale) {
  245. parent.text = decimal
  246. }
  247. return true
  248. }
  249. // Allow the change if it's a valid number or the correct decimal separator
  250. return isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator)
  251. }
  252. public func textFieldDidBeginEditing(_: UITextField) {
  253. parent.textFieldDidBeginEditing?()
  254. }
  255. }
  256. extension UITextField {
  257. func moveCursorToEnd() {
  258. dispatchPrecondition(condition: .onQueue(.main))
  259. let newPosition = endOfDocument
  260. selectedTextRange = textRange(from: newPosition, to: newPosition)
  261. }
  262. }
  263. extension UIApplication {
  264. @objc func endEditing() {
  265. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  266. }
  267. }
  268. public struct TextFieldWithToolBarString: UIViewRepresentable {
  269. @Binding var text: String
  270. var placeholder: String
  271. var textAlignment: NSTextAlignment = .right
  272. var keyboardType: UIKeyboardType = .default
  273. var autocapitalizationType: UITextAutocapitalizationType = .none
  274. var autocorrectionType: UITextAutocorrectionType = .no
  275. var shouldBecomeFirstResponder: Bool = false
  276. var maxLength: Int? = nil
  277. var isDismissible: Bool = true
  278. public func makeUIView(context: Context) -> UITextField {
  279. let textField = UITextField()
  280. context.coordinator.textField = textField
  281. textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
  282. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  283. textField.delegate = context.coordinator
  284. textField.text = text
  285. textField.placeholder = placeholder
  286. textField.textAlignment = textAlignment
  287. textField.keyboardType = keyboardType
  288. textField.autocapitalizationType = autocapitalizationType
  289. textField.autocorrectionType = autocorrectionType
  290. textField.adjustsFontSizeToFitWidth = true
  291. return textField
  292. }
  293. /// Creates and configures a toolbar for the text field with clear and dismiss buttons.
  294. /// - Parameters:
  295. /// - textField: The text field for which the toolbar is being created.
  296. /// - context: The SwiftUI context that contains the coordinator for handling button actions.
  297. /// - Returns: A configured UIToolbar with clear and dismiss buttons.
  298. private func createToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  299. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  300. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  301. let doneButton = UIBarButtonItem(
  302. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  303. style: .done,
  304. target: textField,
  305. action: #selector(UITextField.resignFirstResponder)
  306. )
  307. let clearButton = UIBarButtonItem(
  308. image: UIImage(systemName: "trash"),
  309. style: .plain,
  310. target: context.coordinator,
  311. action: #selector(Coordinator.clearText)
  312. )
  313. toolbar.items = [clearButton, flexibleSpace, doneButton]
  314. toolbar.sizeToFit()
  315. return toolbar
  316. }
  317. public func updateUIView(_ textField: UITextField, context: Context) {
  318. if textField.text != text {
  319. textField.text = text
  320. }
  321. textField.textAlignment = textAlignment
  322. textField.keyboardType = keyboardType
  323. textField.autocapitalizationType = autocapitalizationType
  324. textField.autocorrectionType = autocorrectionType
  325. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  326. if textField.window != nil, textField.becomeFirstResponder() {
  327. context.coordinator.didBecomeFirstResponder = true
  328. }
  329. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  330. context.coordinator.didBecomeFirstResponder = false
  331. }
  332. }
  333. public func makeCoordinator() -> Coordinator {
  334. Coordinator(self, maxLength: maxLength)
  335. }
  336. public final class Coordinator: NSObject {
  337. var parent: TextFieldWithToolBarString
  338. var textField: UITextField?
  339. let maxLength: Int?
  340. var didBecomeFirstResponder = false
  341. init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
  342. self.parent = parent
  343. self.maxLength = maxLength
  344. }
  345. @objc fileprivate func clearText() {
  346. parent.text = ""
  347. textField?.text = ""
  348. }
  349. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  350. DispatchQueue.main.async {
  351. textField.moveCursorToEnd()
  352. }
  353. }
  354. }
  355. }
  356. extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
  357. public func textField(
  358. _ textField: UITextField,
  359. shouldChangeCharactersIn range: NSRange,
  360. replacementString string: String
  361. ) -> Bool {
  362. guard let currentText = textField.text as NSString? else {
  363. return false
  364. }
  365. // Calculate the new text length
  366. let newLength = currentText.length + string.count - range.length
  367. // If there's a maxLength, ensure the new length is within the limit
  368. if let maxLength = parent.maxLength, newLength > maxLength {
  369. return false
  370. }
  371. // Attempt to replace characters in range with the replacement string
  372. let newText = currentText.replacingCharacters(in: range, with: string)
  373. // Update the binding text state
  374. DispatchQueue.main.async {
  375. self.parent.text = newText
  376. }
  377. return true
  378. }
  379. }