TextFieldWithToolBar.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  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 maxValue: Decimal?
  11. var isDismissible: Bool
  12. var textFieldDidBeginEditing: (() -> Void)?
  13. var textDidChange: ((Decimal) -> Void)?
  14. var numberFormatter: NumberFormatter
  15. var allowDecimalSeparator: Bool
  16. var showArrows: Bool
  17. var previousTextField: (() -> Void)?
  18. var nextTextField: (() -> Void)?
  19. var initialFocus: Bool
  20. @FocusState private var isFocused: Bool
  21. @State private var localText: String = ""
  22. // State flag to track if the field was intentionally cleared to zero
  23. @State private var isZeroCleared: Bool = false
  24. public init(
  25. text: Binding<Decimal>,
  26. placeholder: String,
  27. textColor: Color = .primary,
  28. textAlignment: TextAlignment = .trailing,
  29. keyboardType: UIKeyboardType = .decimalPad,
  30. maxLength: Int? = nil,
  31. maxValue: Decimal? = nil,
  32. isDismissible: Bool = true,
  33. textFieldDidBeginEditing: (() -> Void)? = nil,
  34. textDidChange: ((Decimal) -> Void)? = nil,
  35. numberFormatter: NumberFormatter,
  36. allowDecimalSeparator: Bool = true,
  37. showArrows: Bool = false,
  38. previousTextField: (() -> Void)? = nil,
  39. nextTextField: (() -> Void)? = nil,
  40. initialFocus: Bool = false
  41. ) {
  42. _text = text
  43. self.placeholder = placeholder
  44. self.textColor = textColor
  45. self.textAlignment = textAlignment
  46. self.keyboardType = keyboardType
  47. self.maxLength = maxLength
  48. self.maxValue = maxValue
  49. self.isDismissible = isDismissible
  50. self.textFieldDidBeginEditing = textFieldDidBeginEditing
  51. self.textDidChange = textDidChange
  52. self.numberFormatter = numberFormatter
  53. self.numberFormatter.numberStyle = .decimal
  54. self.allowDecimalSeparator = allowDecimalSeparator
  55. self.showArrows = showArrows
  56. self.previousTextField = previousTextField
  57. self.nextTextField = nextTextField
  58. self.initialFocus = initialFocus
  59. }
  60. public var body: some View {
  61. TextField(placeholder, text: $localText)
  62. .focused($isFocused)
  63. .multilineTextAlignment(textAlignment)
  64. .foregroundColor(textColor)
  65. .keyboardType(keyboardType)
  66. .toolbar {
  67. if isFocused {
  68. ToolbarItemGroup(placement: .keyboard) {
  69. Button(action: {
  70. localText = ""
  71. text = 0
  72. isZeroCleared = true // Mark as cleared to prevent showing "0"
  73. textDidChange?(0)
  74. }) {
  75. Image(systemName: "trash")
  76. }
  77. if showArrows {
  78. Button(action: { previousTextField?() }) {
  79. Image(systemName: "chevron.up")
  80. }
  81. Button(action: { nextTextField?() }) {
  82. Image(systemName: "chevron.down")
  83. }
  84. }
  85. Spacer()
  86. if isDismissible {
  87. Button(action: { isFocused = false }) {
  88. Image(systemName: "keyboard.chevron.compact.down")
  89. }
  90. }
  91. }
  92. }
  93. }
  94. .onChange(of: isFocused) { _, newValue in
  95. if newValue {
  96. textFieldDidBeginEditing?()
  97. // When gaining focus: if the value is zero and was previously cleared,
  98. // keep the text field empty to show placeholder instead of "0"
  99. if isZeroCleared, text == 0 {
  100. localText = ""
  101. }
  102. } else {
  103. // When losing focus: handle formatting and validation
  104. if localText.isEmpty {
  105. // If field is empty, maintain zero value but mark as cleared
  106. // so we can show placeholder instead of "0"
  107. text = 0
  108. isZeroCleared = true
  109. } else if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
  110. if decimal != 0 {
  111. // For non-zero values, format normally and update binding
  112. text = decimal
  113. localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
  114. isZeroCleared = false
  115. } else {
  116. // If user explicitly entered zero, store the value but
  117. // keep display empty to show placeholder
  118. text = 0
  119. localText = ""
  120. isZeroCleared = true
  121. }
  122. }
  123. }
  124. }
  125. .onChange(of: localText) { oldValue, newValue in
  126. // Reset zero-cleared state as soon as user starts typing anything
  127. if !newValue.isEmpty {
  128. isZeroCleared = false
  129. }
  130. // Special handling for backspace operations to maintain decimal format
  131. if oldValue.count == newValue.count + 1 {
  132. let decimalSeparator = numberFormatter.decimalSeparator ?? "."
  133. // Special case: When backspacing to leave only a decimal point
  134. // e.g., "10.1" -> "10." - Keep decimal separator without adding trailing zero
  135. if newValue.hasSuffix(decimalSeparator) {
  136. if let decimal = Decimal(string: newValue + "0", locale: numberFormatter.locale) {
  137. text = decimal
  138. textDidChange?(decimal)
  139. }
  140. return
  141. }
  142. // Special case: When backspacing the last digit after a decimal point
  143. // e.g., "10.0" -> "10." - Ensure we keep proper decimal format
  144. if oldValue.contains(decimalSeparator), newValue.contains(decimalSeparator) {
  145. let oldParts = oldValue.components(separatedBy: decimalSeparator)
  146. let newParts = newValue.components(separatedBy: decimalSeparator)
  147. // Check if we've removed the last digit after decimal point
  148. if oldParts.count > 1, newParts.count > 1,
  149. oldParts[1].count == 1, newParts[1].isEmpty
  150. {
  151. // Keep proper decimal format by adding trailing zero
  152. localText = newParts[0] + decimalSeparator + "0"
  153. if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
  154. text = decimal
  155. textDidChange?(decimal)
  156. }
  157. return
  158. }
  159. }
  160. }
  161. // Process normal text input changes
  162. handleTextChange(oldValue, newValue)
  163. }
  164. .onChange(of: text) { oldValue, newValue in
  165. // Handle external changes to the text binding
  166. // (changes not initiated by typing, like programmatic changes)
  167. if oldValue != newValue,
  168. Decimal(string: localText, locale: numberFormatter.locale) != newValue
  169. {
  170. if newValue == 0, isZeroCleared {
  171. // If value is zero and field was cleared, keep display empty to show placeholder
  172. localText = ""
  173. } else {
  174. // Otherwise format and display the new value
  175. localText = numberFormatter.string(from: newValue as NSNumber) ?? ""
  176. isZeroCleared = false
  177. }
  178. }
  179. }
  180. .onAppear {
  181. if text != 0 {
  182. // Initialize with formatted non-zero value
  183. localText = numberFormatter.string(from: text as NSNumber) ?? ""
  184. isZeroCleared = false
  185. } else {
  186. // For zero values, start with empty field to show placeholder
  187. localText = ""
  188. isZeroCleared = true
  189. }
  190. // Set initial focus if requested
  191. isFocused = initialFocus
  192. }
  193. }
  194. private func handleTextChange(_ oldValue: String, _ newValue: String) {
  195. // Handle empty input (clear operation)
  196. if newValue.isEmpty {
  197. text = 0
  198. isZeroCleared = true
  199. textDidChange?(0)
  200. return
  201. }
  202. // Remove leading zeros except for decimal values (e.g., "0.5")
  203. // This prevents inputs like "01", "0123", etc. but allows "0.5"
  204. if newValue.count > 1 && newValue.hasPrefix("0") && !newValue.hasPrefix("0" + (numberFormatter.decimalSeparator ?? ".")) {
  205. localText = String(newValue.dropFirst())
  206. return
  207. }
  208. let currentDecimalSeparator = numberFormatter.decimalSeparator ?? "."
  209. // Ensure there's only one decimal separator
  210. let decimalSeparatorCount = newValue.filter { String($0) == currentDecimalSeparator }.count
  211. if decimalSeparatorCount > 1 {
  212. // Reject input with multiple decimal separators
  213. localText = oldValue
  214. return
  215. }
  216. // Handle localization by converting to the correct decimal separator
  217. var processedText = newValue
  218. if newValue.contains("."), currentDecimalSeparator != "." {
  219. processedText = newValue.replacingOccurrences(of: ".", with: currentDecimalSeparator)
  220. } else if newValue.contains(","), currentDecimalSeparator != "," {
  221. processedText = newValue.replacingOccurrences(of: ",", with: currentDecimalSeparator)
  222. }
  223. // Automatically add leading zero when starting with decimal separator
  224. // For example ".5" becomes "0.5"
  225. if processedText.hasPrefix(currentDecimalSeparator) {
  226. processedText = "0" + processedText
  227. }
  228. // Validate against number formatter digit limits
  229. let components = processedText.components(separatedBy: currentDecimalSeparator)
  230. // Process the integer part (before decimal)
  231. var integerPart = components[0].filter { $0.isNumber }
  232. // Remove leading zeros for accurate digit counting
  233. while integerPart.hasPrefix("0") && integerPart.count > 1 {
  234. integerPart.removeFirst()
  235. }
  236. let integerDigits = integerPart.count
  237. // Count fraction digits (after decimal separator)
  238. let fractionDigits = components.count > 1 ? components[1].filter { $0.isNumber }.count : 0
  239. // Validate against the formatter's digit limits
  240. if integerDigits > numberFormatter.maximumIntegerDigits ||
  241. (allowDecimalSeparator && fractionDigits > numberFormatter.maximumFractionDigits)
  242. {
  243. // Reject input that exceeds digit limits
  244. localText = oldValue
  245. return
  246. }
  247. // Parse and validate the decimal value
  248. if let decimal = Decimal(string: processedText, locale: numberFormatter.locale) {
  249. if let maxValue = maxValue, decimal > maxValue {
  250. // Cap at maximum allowed value
  251. text = maxValue
  252. localText = numberFormatter.string(from: maxValue as NSNumber) ?? ""
  253. isZeroCleared = false
  254. } else {
  255. // Accept valid input and update binding
  256. text = decimal
  257. // Update zero-cleared state based on the value
  258. isZeroCleared = (decimal == 0) && localText.isEmpty
  259. textDidChange?(decimal)
  260. // If we had to process/modify the input, update the displayed text
  261. if processedText != newValue {
  262. localText = processedText
  263. }
  264. }
  265. } else {
  266. // Reject invalid decimal inputs
  267. localText = oldValue
  268. }
  269. }
  270. }
  271. extension UITextField {
  272. func moveCursorToEnd() {
  273. dispatchPrecondition(condition: .onQueue(.main))
  274. let newPosition = endOfDocument
  275. selectedTextRange = textRange(from: newPosition, to: newPosition)
  276. }
  277. }
  278. extension UIApplication {
  279. @objc func endEditing() {
  280. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
  281. }
  282. }
  283. public struct TextFieldWithToolBarString: UIViewRepresentable {
  284. @Binding var text: String
  285. var placeholder: String
  286. var textAlignment: NSTextAlignment = .right
  287. var keyboardType: UIKeyboardType = .default
  288. var autocapitalizationType: UITextAutocapitalizationType = .none
  289. var autocorrectionType: UITextAutocorrectionType = .no
  290. var shouldBecomeFirstResponder: Bool = false
  291. var maxLength: Int? = nil
  292. var isDismissible: Bool = true
  293. public func makeUIView(context: Context) -> UITextField {
  294. let textField = UITextField()
  295. context.coordinator.textField = textField
  296. textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
  297. textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
  298. textField.delegate = context.coordinator
  299. textField.text = text
  300. textField.placeholder = placeholder
  301. textField.textAlignment = textAlignment
  302. textField.keyboardType = keyboardType
  303. textField.autocapitalizationType = autocapitalizationType
  304. textField.autocorrectionType = autocorrectionType
  305. textField.adjustsFontSizeToFitWidth = true
  306. return textField
  307. }
  308. /// Creates and configures a toolbar for the text field with clear and dismiss buttons.
  309. /// - Parameters:
  310. /// - textField: The text field for which the toolbar is being created.
  311. /// - context: The SwiftUI context that contains the coordinator for handling button actions.
  312. /// - Returns: A configured UIToolbar with clear and dismiss buttons.
  313. private func createToolbar(for textField: UITextField, context: Context) -> UIToolbar {
  314. let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
  315. let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  316. let doneButton = UIBarButtonItem(
  317. image: UIImage(systemName: "keyboard.chevron.compact.down"),
  318. style: .done,
  319. target: textField,
  320. action: #selector(UITextField.resignFirstResponder)
  321. )
  322. let clearButton = UIBarButtonItem(
  323. image: UIImage(systemName: "trash"),
  324. style: .plain,
  325. target: context.coordinator,
  326. action: #selector(Coordinator.clearText)
  327. )
  328. toolbar.items = [clearButton, flexibleSpace, doneButton]
  329. toolbar.sizeToFit()
  330. return toolbar
  331. }
  332. public func updateUIView(_ textField: UITextField, context: Context) {
  333. if textField.text != text {
  334. textField.text = text
  335. }
  336. textField.textAlignment = textAlignment
  337. textField.keyboardType = keyboardType
  338. textField.autocapitalizationType = autocapitalizationType
  339. textField.autocorrectionType = autocorrectionType
  340. if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
  341. if textField.window != nil, textField.becomeFirstResponder() {
  342. context.coordinator.didBecomeFirstResponder = true
  343. }
  344. } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
  345. context.coordinator.didBecomeFirstResponder = false
  346. }
  347. }
  348. public func makeCoordinator() -> Coordinator {
  349. Coordinator(self, maxLength: maxLength)
  350. }
  351. public final class Coordinator: NSObject {
  352. var parent: TextFieldWithToolBarString
  353. var textField: UITextField?
  354. let maxLength: Int?
  355. var didBecomeFirstResponder = false
  356. init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
  357. self.parent = parent
  358. self.maxLength = maxLength
  359. }
  360. @objc fileprivate func clearText() {
  361. parent.text = ""
  362. textField?.text = ""
  363. }
  364. @objc fileprivate func editingDidBegin(_ textField: UITextField) {
  365. DispatchQueue.main.async {
  366. textField.moveCursorToEnd()
  367. }
  368. }
  369. }
  370. }
  371. extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
  372. public func textField(
  373. _ textField: UITextField,
  374. shouldChangeCharactersIn range: NSRange,
  375. replacementString string: String
  376. ) -> Bool {
  377. guard let currentText = textField.text as NSString? else {
  378. return false
  379. }
  380. // Calculate the new text length
  381. let newLength = currentText.length + string.count - range.length
  382. // If there's a maxLength, ensure the new length is within the limit
  383. if let maxLength = parent.maxLength, newLength > maxLength {
  384. return false
  385. }
  386. // Attempt to replace characters in range with the replacement string
  387. let newText = currentText.replacingCharacters(in: range, with: string)
  388. // Update the binding text state
  389. DispatchQueue.main.async {
  390. self.parent.text = newText
  391. }
  392. return true
  393. }
  394. }