TextFieldWithToolBar.swift 19 KB

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