TextFieldWithToolBar.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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. public init(
  18. text: Binding<Decimal>,
  19. placeholder: String,
  20. textColor: UIColor = .label,
  21. textAlignment: NSTextAlignment = .right,
  22. keyboardType: UIKeyboardType = .decimalPad,
  23. autocapitalizationType: UITextAutocapitalizationType = .none,
  24. autocorrectionType: UITextAutocorrectionType = .no,
  25. shouldBecomeFirstResponder: Bool = false,
  26. maxLength: Int? = nil,
  27. isDismissible: Bool = true,
  28. textFieldDidBeginEditing: (() -> Void)? = nil,
  29. numberFormatter: NumberFormatter,
  30. allowDecimalSeparator: Bool = true
  31. ) {
  32. _text = text
  33. self.placeholder = placeholder
  34. self.textColor = textColor
  35. self.textAlignment = textAlignment
  36. self.keyboardType = keyboardType
  37. self.autocapitalizationType = autocapitalizationType
  38. self.autocorrectionType = autocorrectionType
  39. self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
  40. self.maxLength = maxLength
  41. self.isDismissible = isDismissible
  42. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  43. self.numberFormatter = numberFormatter
  44. self.numberFormatter.numberStyle = .decimal
  45. self.allowDecimalSeparator = allowDecimalSeparator
  46. }
  47. public func makeUIView(context: Context) -> UITextField {
  48. let textField = UITextField()
  49. context.coordinator.textField = textField
  50. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  51. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  52. textField.delegate = context.coordinator
  53. if text == 0 { /// show no value initially, i.e. empty String
  54. textField.text = ""
  55. } else {
  56. textField.text = numberFormatter.string(for: text)
  57. }
  58. textField.placeholder = placeholder
  59. return textField
  60. }
  61. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  62. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  63. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  64. let doneButton = UIBarButtonItem(
  65. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  66. style: .done,
  67. target: textField,
  68. action: #selector(UITextField.resignFirstResponder)
  69. )
  70. let clearButton = UIBarButtonItem(
  71. image: UIImage(systemName: "trash"),
  72. style: .plain,
  73. target: context.coordinator,
  74. action: #selector(Coordinator.clearText)
  75. )
  76. toolbar.items = [clearButton, flexibleSpace, doneButton]
  77. toolbar.sizeToFit()
  78. return toolbar
  79. }
  80. public func updateUIView(_ textField: UITextField, context: Context) {
  81. if text != 0 {
  82. let newText = numberFormatter.string(for: text) ?? ""
  83. if textField.text != newText {
  84. textField.text = newText
  85. }
  86. }
  87. textField.textColor = textColor
  88. textField.textAlignment = textAlignment
  89. textField.keyboardType = keyboardType
  90. textField.autocapitalizationType = autocapitalizationType
  91. textField.autocorrectionType = autocorrectionType
  92. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  93. if textField.window != nil, textField.becomeFirstResponder() {
  94. context.coordinator.didBecomeFirstResponder = true
  95. }
  96. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  97. context.coordinator.didBecomeFirstResponder = false
  98. }
  99. }
  100. public func makeCoordinator() -> Coordinator {
  101. Coordinator(self, maxLength: maxLength)
  102. }
  103. public final class Coordinator: NSObject {
  104. var parent: TextFieldWithToolBar
  105. var textField: UITextField?
  106. let maxLength: Int?
  107. var didBecomeFirstResponder = false
  108. let decimalFormatter: NumberFormatter
  109. init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
  110. self.parent = parent
  111. self.maxLength = maxLength
  112. decimalFormatter = NumberFormatter()
  113. decimalFormatter.locale = Locale.current
  114. decimalFormatter.numberStyle = .decimal
  115. }
  116. @objc fileprivate func clearText() {
  117. parent.text = 0
  118. textField?.text = ""
  119. }
  120. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  121. DispatchQueue.main.async {
  122. textField.moveCursorToEnd()
  123. }
  124. }
  125. }
  126. }
  127. extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
  128. public func textField(
  129. _ textField: UITextField,
  130. shouldChangeCharactersIn range: NSRange,
  131. replacementString string: String
  132. ) -> Bool {
  133. // Check if the input is a number or the decimal separator
  134. let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
  135. let isDecimalSeparator = (string == decimalFormatter.decimalSeparator && textField.text?.contains(string) == false)
  136. var allowChange = true
  137. // Only proceed if the input is a valid number or decimal separator
  138. if isNumber || isDecimalSeparator && parent.allowDecimalSeparator,
  139. let currentText = textField.text as NSString?
  140. {
  141. // Get the proposed new text
  142. let proposedTextOriginal = currentText.replacingCharacters(in: range, with: string)
  143. // Remove thousand separator
  144. let proposedText = proposedTextOriginal.replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")
  145. let number = parent.numberFormatter.number(from: proposedText) ?? decimalFormatter.number(from: proposedText)
  146. // Update the binding value if conversion is successful
  147. if let number = number {
  148. let lastCharIndex = proposedText.index(before: proposedText.endIndex)
  149. let hasDecimalSeparator = proposedText.contains(decimalFormatter.decimalSeparator)
  150. let hasTrailingZeros = (
  151. parent.numberFormatter
  152. .maximumFractionDigits > 1 && hasDecimalSeparator && proposedText[lastCharIndex] == "0"
  153. ) ||
  154. (isDecimalSeparator && parent.numberFormatter.allowsFloats)
  155. if !parent.numberFormatter.allowsFloats || !hasTrailingZeros
  156. {
  157. parent.text = number.decimalValue
  158. }
  159. if parent.numberFormatter.allowsFloats, hasDecimalSeparator {
  160. let rangeOfDecimal = proposedText.range(of: decimalFormatter.decimalSeparator)
  161. let decimalIndexInt: Int = proposedText.distance(
  162. from: proposedText.startIndex,
  163. to: rangeOfDecimal!.lowerBound
  164. )
  165. let maxDigits = decimalIndexInt + parent.numberFormatter.maximumFractionDigits + 1
  166. allowChange = proposedText.count > maxDigits ? false : true
  167. // Remove trailing zeros when FractionDigits are all zero
  168. if proposedText.count == maxDigits {
  169. var pattern = "[" + decimalFormatter.decimalSeparator + "]{1}[0]"
  170. pattern += "{" + String(parent.numberFormatter.maximumFractionDigits) + "}"
  171. let regex = NSRegularExpression(pattern)
  172. let matches = regex.matches(proposedText)
  173. if matches {
  174. let resultText = String(proposedText[...rangeOfDecimal!.lowerBound])
  175. let trailingZerosNumber = parent.numberFormatter.number(from: resultText)
  176. parent.text = trailingZerosNumber?.decimalValue ?? 0
  177. }
  178. }
  179. }
  180. } else {
  181. parent.text = 0
  182. }
  183. }
  184. // Allow the change if it's a valid number or decimal separator
  185. return isNumber && allowChange || isDecimalSeparator && parent.allowDecimalSeparator && parent.numberFormatter
  186. .allowsFloats
  187. }
  188. public func textFieldDidBeginEditing(_: UITextField) {
  189. parent.textFieldDidBeginEditing?()
  190. }
  191. }
  192. extension NSRegularExpression {
  193. convenience init(_ pattern: String) {
  194. do {
  195. try self.init(pattern: pattern)
  196. } catch {
  197. preconditionFailure("Illegal regular expression: \(pattern).")
  198. }
  199. }
  200. func matches(_ string: String) -> Bool {
  201. let range = NSRange(location: 0, length: string.utf16.count)
  202. return firstMatch(in: string, options: [], range: range) != nil
  203. }
  204. }
  205. extension UITextField {
  206. func moveCursorToEnd() {
  207. dispatchPrecondition(condition: .onQueue(.main))
  208. let newPosition = endOfDocument
  209. selectedTextRange = textRange(from: newPosition, to: newPosition)
  210. }
  211. }
  212. extension UIApplication {
  213. func endEditing() {
  214. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  215. }
  216. }
  217. public struct TextFieldWithToolBarString: UIViewRepresentable {
  218. @Binding var text: String
  219. var placeholder: String
  220. var textAlignment: NSTextAlignment = .right
  221. var keyboardType: UIKeyboardType = .default
  222. var autocapitalizationType: UITextAutocapitalizationType = .none
  223. var autocorrectionType: UITextAutocorrectionType = .no
  224. var shouldBecomeFirstResponder: Bool = false
  225. var maxLength: Int? = nil
  226. var isDismissible: Bool = true
  227. public func makeUIView(context: Context) -> UITextField {
  228. let textField = UITextField()
  229. context.coordinator.textField = textField
  230. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  231. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  232. textField.delegate = context.coordinator
  233. textField.text = text
  234. textField.placeholder = placeholder
  235. textField.textAlignment = textAlignment
  236. textField.keyboardType = keyboardType
  237. textField.autocapitalizationType = autocapitalizationType
  238. textField.autocorrectionType = autocorrectionType
  239. textField.adjustsFontSizeToFitWidth = true
  240. return textField
  241. }
  242. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  243. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  244. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  245. let doneButton = UIBarButtonItem(
  246. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  247. style: .done,
  248. target: textField,
  249. action: #selector(UITextField.resignFirstResponder)
  250. )
  251. let clearButton = UIBarButtonItem(
  252. image: UIImage(systemName: "trash"),
  253. style: .plain,
  254. target: context.coordinator,
  255. action: #selector(Coordinator.clearText)
  256. )
  257. toolbar.items = [clearButton, flexibleSpace, doneButton]
  258. toolbar.sizeToFit()
  259. return toolbar
  260. }
  261. public func updateUIView(_ textField: UITextField, context: Context) {
  262. if textField.text != text {
  263. textField.text = text
  264. }
  265. textField.textAlignment = textAlignment
  266. textField.keyboardType = keyboardType
  267. textField.autocapitalizationType = autocapitalizationType
  268. textField.autocorrectionType = autocorrectionType
  269. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  270. if textField.window != nil, textField.becomeFirstResponder() {
  271. context.coordinator.didBecomeFirstResponder = true
  272. }
  273. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  274. context.coordinator.didBecomeFirstResponder = false
  275. }
  276. }
  277. public func makeCoordinator() -> Coordinator {
  278. Coordinator(self, maxLength: maxLength)
  279. }
  280. public final class Coordinator: NSObject {
  281. var parent: TextFieldWithToolBarString
  282. var textField: UITextField?
  283. let maxLength: Int?
  284. var didBecomeFirstResponder = false
  285. init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
  286. self.parent = parent
  287. self.maxLength = maxLength
  288. }
  289. @objc fileprivate func clearText() {
  290. parent.text = ""
  291. textField?.text = ""
  292. }
  293. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  294. DispatchQueue.main.async {
  295. textField.moveCursorToEnd()
  296. }
  297. }
  298. }
  299. }
  300. extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
  301. public func textField(
  302. _ textField: UITextField,
  303. shouldChangeCharactersIn range: NSRange,
  304. replacementString string: String
  305. ) -> Bool {
  306. if let maxLength = parent.maxLength {
  307. // Get the current text, including the proposed change
  308. let currentText = textField.text ?? ""
  309. let newLength = currentText.count + string.count - range.length
  310. if newLength > maxLength {
  311. return false
  312. }
  313. }
  314. DispatchQueue.main.async {
  315. if let textFieldText = textField.text as NSString? {
  316. let newText = textFieldText.replacingCharacters(in: range, with: string)
  317. self.parent.text = newText
  318. }
  319. }
  320. return true
  321. }
  322. }