TextFieldWithToolBar.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. //
  2. // TextFieldWithToolBar.swift
  3. // LoopFollow
  4. //
  5. // Created by Jonas Björkert on 2024-07-27.
  6. // Copyright © 2024 Jon Fawcett. All rights reserved.
  7. //
  8. import SwiftUI
  9. import UIKit
  10. public struct TextFieldWithToolBar: UIViewRepresentable {
  11. @Binding var text: Double
  12. var placeholder: String
  13. var textColor: UIColor
  14. var textAlignment: NSTextAlignment
  15. var keyboardType: UIKeyboardType
  16. var autocapitalizationType: UITextAutocapitalizationType
  17. var autocorrectionType: UITextAutocorrectionType
  18. var shouldBecomeFirstResponder: Bool
  19. var maxLength: Int?
  20. var isDismissible: Bool
  21. var textFieldDidBeginEditing: (() -> Void)?
  22. var numberFormatter: NumberFormatter
  23. var allowDecimalSeparator: Bool
  24. public init(
  25. text: Binding<Double>,
  26. placeholder: String,
  27. textColor: UIColor = .label,
  28. textAlignment: NSTextAlignment = .right,
  29. keyboardType: UIKeyboardType = .decimalPad,
  30. autocapitalizationType: UITextAutocapitalizationType = .none,
  31. autocorrectionType: UITextAutocorrectionType = .no,
  32. shouldBecomeFirstResponder: Bool = false,
  33. maxLength: Int? = nil,
  34. isDismissible: Bool = true,
  35. textFieldDidBeginEditing: (() -> Void)? = nil,
  36. numberFormatter: NumberFormatter,
  37. allowDecimalSeparator: Bool = true
  38. ) {
  39. _text = text
  40. self.placeholder = placeholder
  41. self.textColor = textColor
  42. self.textAlignment = textAlignment
  43. self.keyboardType = keyboardType
  44. self.autocapitalizationType = autocapitalizationType
  45. self.autocorrectionType = autocorrectionType
  46. self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
  47. self.maxLength = maxLength
  48. self.isDismissible = isDismissible
  49. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  50. self.numberFormatter = numberFormatter
  51. self.numberFormatter.numberStyle = .decimal
  52. self.allowDecimalSeparator = allowDecimalSeparator
  53. }
  54. public func makeUIView(context: Context) -> UITextField {
  55. let textField = UITextField()
  56. context.coordinator.textField = textField
  57. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  58. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  59. textField.delegate = context.coordinator
  60. if text == 0 { /// show no value initially, i.e. empty String
  61. textField.text = ""
  62. } else {
  63. textField.text = numberFormatter.string(for: text)
  64. }
  65. textField.placeholder = placeholder
  66. return textField
  67. }
  68. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  69. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  70. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  71. let doneButton = UIBarButtonItem(
  72. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  73. style: .done,
  74. target: textField,
  75. action: #selector(UITextField.resignFirstResponder)
  76. )
  77. let clearButton = UIBarButtonItem(
  78. image: UIImage(systemName: "trash"),
  79. style: .plain,
  80. target: context.coordinator,
  81. action: #selector(Coordinator.clearText)
  82. )
  83. toolbar.items = [clearButton, flexibleSpace, doneButton]
  84. toolbar.sizeToFit()
  85. return toolbar
  86. }
  87. public func updateUIView(_ textField: UITextField, context: Context) {
  88. if text != 0 {
  89. let newText = numberFormatter.string(for: text) ?? ""
  90. if textField.text != newText {
  91. textField.text = newText
  92. }
  93. }
  94. textField.textColor = textColor
  95. textField.textAlignment = textAlignment
  96. textField.keyboardType = keyboardType
  97. textField.autocapitalizationType = autocapitalizationType
  98. textField.autocorrectionType = autocorrectionType
  99. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  100. if textField.window != nil, textField.becomeFirstResponder() {
  101. context.coordinator.didBecomeFirstResponder = true
  102. }
  103. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  104. context.coordinator.didBecomeFirstResponder = false
  105. }
  106. }
  107. public func makeCoordinator() -> Coordinator {
  108. Coordinator(self, maxLength: maxLength)
  109. }
  110. public final class Coordinator: NSObject {
  111. var parent: TextFieldWithToolBar
  112. var textField: UITextField?
  113. let maxLength: Int?
  114. var didBecomeFirstResponder = false
  115. let decimalFormatter: NumberFormatter
  116. init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
  117. self.parent = parent
  118. self.maxLength = maxLength
  119. decimalFormatter = NumberFormatter()
  120. decimalFormatter.locale = Locale.current
  121. decimalFormatter.numberStyle = .decimal
  122. }
  123. @objc fileprivate func clearText() {
  124. parent.text = 0
  125. textField?.text = ""
  126. }
  127. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  128. DispatchQueue.main.async {
  129. textField.moveCursorToEnd()
  130. }
  131. }
  132. }
  133. }
  134. extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
  135. public func textField(
  136. _ textField: UITextField,
  137. shouldChangeCharactersIn range: NSRange,
  138. replacementString string: String
  139. ) -> Bool {
  140. // Check if the input is a number or the decimal separator
  141. let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
  142. let isDecimalSeparator = (string == decimalFormatter.decimalSeparator && textField.text?.contains(string) == false)
  143. // Only proceed if the input is a valid number or decimal separator
  144. if isNumber || isDecimalSeparator && parent.allowDecimalSeparator,
  145. let currentText = textField.text as NSString?
  146. {
  147. // Get the proposed new text
  148. let proposedTextOriginal = currentText.replacingCharacters(in: range, with: string)
  149. // Remove thousand separator
  150. let proposedText = proposedTextOriginal.replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")
  151. // Try to convert proposed text to number
  152. let number = parent.numberFormatter.number(from: proposedText) ?? decimalFormatter.number(from: proposedText)
  153. // Update the binding value if conversion is successful
  154. if let number = number {
  155. let lastCharIndex = proposedText.index(before: proposedText.endIndex)
  156. let hasDecimalSeparator = proposedText.contains(decimalFormatter.decimalSeparator)
  157. let hasTrailingZeros = (hasDecimalSeparator && proposedText[lastCharIndex] == "0") || isDecimalSeparator
  158. if !hasTrailingZeros
  159. {
  160. parent.text = number.doubleValue
  161. }
  162. } else {
  163. parent.text = 0
  164. }
  165. }
  166. // Allow the change if it's a valid number or decimal separator
  167. return isNumber || isDecimalSeparator && parent.allowDecimalSeparator
  168. }
  169. public func textFieldDidBeginEditing(_: UITextField) {
  170. parent.textFieldDidBeginEditing?()
  171. }
  172. }
  173. extension UITextField {
  174. func moveCursorToEnd() {
  175. dispatchPrecondition(condition: .onQueue(.main))
  176. let newPosition = endOfDocument
  177. selectedTextRange = textRange(from: newPosition, to: newPosition)
  178. }
  179. }
  180. extension UIApplication {
  181. func endEditing() {
  182. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  183. }
  184. }
  185. public struct TextFieldWithToolBarString: UIViewRepresentable {
  186. @Binding var text: String
  187. var placeholder: String
  188. var textAlignment: NSTextAlignment = .right
  189. var keyboardType: UIKeyboardType = .default
  190. var autocapitalizationType: UITextAutocapitalizationType = .none
  191. var autocorrectionType: UITextAutocorrectionType = .no
  192. var shouldBecomeFirstResponder: Bool = false
  193. var maxLength: Int? = nil
  194. var isDismissible: Bool = true
  195. public func makeUIView(context: Context) -> UITextField {
  196. let textField = UITextField()
  197. context.coordinator.textField = textField
  198. textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
  199. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  200. textField.delegate = context.coordinator
  201. textField.text = text
  202. textField.placeholder = placeholder
  203. textField.textAlignment = textAlignment
  204. textField.keyboardType = keyboardType
  205. textField.autocapitalizationType = autocapitalizationType
  206. textField.autocorrectionType = autocorrectionType
  207. textField.adjustsFontSizeToFitWidth = true
  208. return textField
  209. }
  210. private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  211. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  212. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  213. let doneButton = UIBarButtonItem(
  214. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  215. style: .done,
  216. target: textField,
  217. action: #selector(UITextField.resignFirstResponder)
  218. )
  219. let clearButton = UIBarButtonItem(
  220. image: UIImage(systemName: "trash"),
  221. style: .plain,
  222. target: context.coordinator,
  223. action: #selector(Coordinator.clearText)
  224. )
  225. toolbar.items = [clearButton, flexibleSpace, doneButton]
  226. toolbar.sizeToFit()
  227. return toolbar
  228. }
  229. public func updateUIView(_ textField: UITextField, context: Context) {
  230. if textField.text != text {
  231. textField.text = text
  232. }
  233. textField.textAlignment = textAlignment
  234. textField.keyboardType = keyboardType
  235. textField.autocapitalizationType = autocapitalizationType
  236. textField.autocorrectionType = autocorrectionType
  237. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  238. if textField.window != nil, textField.becomeFirstResponder() {
  239. context.coordinator.didBecomeFirstResponder = true
  240. }
  241. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  242. context.coordinator.didBecomeFirstResponder = false
  243. }
  244. }
  245. public func makeCoordinator() -> Coordinator {
  246. Coordinator(self, maxLength: maxLength)
  247. }
  248. public final class Coordinator: NSObject {
  249. var parent: TextFieldWithToolBarString
  250. var textField: UITextField?
  251. let maxLength: Int?
  252. var didBecomeFirstResponder = false
  253. init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
  254. self.parent = parent
  255. self.maxLength = maxLength
  256. }
  257. @objc fileprivate func clearText() {
  258. parent.text = ""
  259. textField?.text = ""
  260. }
  261. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  262. DispatchQueue.main.async {
  263. textField.moveCursorToEnd()
  264. }
  265. }
  266. }
  267. }
  268. extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
  269. public func textField(
  270. _ textField: UITextField,
  271. shouldChangeCharactersIn range: NSRange,
  272. replacementString string: String
  273. ) -> Bool {
  274. guard let currentText = textField.text as NSString? else {
  275. return false
  276. }
  277. // Calculate the new text length
  278. let newLength = currentText.length + string.count - range.length
  279. // If there's a maxLength, ensure the new length is within the limit
  280. if let maxLength = parent.maxLength, newLength > maxLength {
  281. return false
  282. }
  283. // Attempt to replace characters in range with the replacement string
  284. let newText = currentText.replacingCharacters(in: range, with: string)
  285. // Update the binding text state
  286. DispatchQueue.main.async {
  287. self.parent.text = newText
  288. }
  289. return true
  290. }
  291. }