| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- import SwiftUI
- import UIKit
- public struct TextFieldWithToolBar: View {
- @Binding var text: Decimal
- var placeholder: String
- var textColor: Color
- var textAlignment: TextAlignment
- var keyboardType: UIKeyboardType
- var maxLength: Int?
- var maxValue: Decimal?
- var isDismissible: Bool
- var textFieldDidBeginEditing: (() -> Void)?
- var textDidChange: ((Decimal) -> Void)?
- var numberFormatter: NumberFormatter
- var allowDecimalSeparator: Bool
- var showArrows: Bool
- var previousTextField: (() -> Void)?
- var nextTextField: (() -> Void)?
- var initialFocus: Bool
- @FocusState private var isFocused: Bool
- @State private var localText: String = ""
- // State flag to track if the field was intentionally cleared to zero
- @State private var isZeroCleared: Bool = false
- public init(
- text: Binding<Decimal>,
- placeholder: String,
- textColor: Color = .primary,
- textAlignment: TextAlignment = .trailing,
- keyboardType: UIKeyboardType = .decimalPad,
- maxLength: Int? = nil,
- maxValue: Decimal? = nil,
- isDismissible: Bool = true,
- textFieldDidBeginEditing: (() -> Void)? = nil,
- textDidChange: ((Decimal) -> Void)? = nil,
- numberFormatter: NumberFormatter,
- allowDecimalSeparator: Bool = true,
- showArrows: Bool = false,
- previousTextField: (() -> Void)? = nil,
- nextTextField: (() -> Void)? = nil,
- initialFocus: Bool = false
- ) {
- _text = text
- self.placeholder = placeholder
- self.textColor = textColor
- self.textAlignment = textAlignment
- self.keyboardType = keyboardType
- self.maxLength = maxLength
- self.maxValue = maxValue
- self.isDismissible = isDismissible
- self.textFieldDidBeginEditing = textFieldDidBeginEditing
- self.textDidChange = textDidChange
- self.numberFormatter = numberFormatter
- self.numberFormatter.numberStyle = .decimal
- self.allowDecimalSeparator = allowDecimalSeparator
- self.showArrows = showArrows
- self.previousTextField = previousTextField
- self.nextTextField = nextTextField
- self.initialFocus = initialFocus
- }
- public var body: some View {
- TextField(placeholder, text: $localText)
- .focused($isFocused)
- .multilineTextAlignment(textAlignment)
- .foregroundColor(textColor)
- .keyboardType(keyboardType)
- .toolbar {
- if isFocused {
- ToolbarItemGroup(placement: .keyboard) {
- Button(action: {
- localText = ""
- text = 0
- isZeroCleared = true // Mark as cleared to prevent showing "0"
- textDidChange?(0)
- }) {
- Image(systemName: "trash")
- }
- if showArrows {
- Button(action: { previousTextField?() }) {
- Image(systemName: "chevron.up")
- }
- Button(action: { nextTextField?() }) {
- Image(systemName: "chevron.down")
- }
- }
- Spacer()
- if isDismissible {
- Button(action: { isFocused = false }) {
- Image(systemName: "keyboard.chevron.compact.down")
- }
- }
- }
- }
- }
- .onChange(of: isFocused) { _, newValue in
- if newValue {
- textFieldDidBeginEditing?()
- // When gaining focus: if the value is zero and was previously cleared,
- // keep the text field empty to show placeholder instead of "0"
- if isZeroCleared, text == 0 {
- localText = ""
- }
- } else {
- // When losing focus: handle formatting and validation
- if localText.isEmpty {
- // If field is empty, maintain zero value but mark as cleared
- // so we can show placeholder instead of "0"
- text = 0
- isZeroCleared = true
- } else if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
- if decimal != 0 {
- // For non-zero values, format normally and update binding
- text = decimal
- localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
- isZeroCleared = false
- } else {
- // If user explicitly entered zero, store the value but
- // keep display empty to show placeholder
- text = 0
- localText = ""
- isZeroCleared = true
- }
- }
- }
- }
- .onChange(of: localText) { oldValue, newValue in
- // Reset zero-cleared state as soon as user starts typing anything
- if !newValue.isEmpty {
- isZeroCleared = false
- }
- // Special handling for backspace operations to maintain decimal format
- if oldValue.count == newValue.count + 1 {
- let decimalSeparator = numberFormatter.decimalSeparator ?? "."
- // Special case: When backspacing to leave only a decimal point
- // e.g., "10.1" -> "10." - Keep decimal separator without adding trailing zero
- if newValue.hasSuffix(decimalSeparator) {
- if let decimal = Decimal(string: newValue + "0", locale: numberFormatter.locale) {
- text = decimal
- textDidChange?(decimal)
- }
- return
- }
- // Special case: When backspacing the last digit after a decimal point
- // e.g., "10.0" -> "10." - Ensure we keep proper decimal format
- if oldValue.contains(decimalSeparator), newValue.contains(decimalSeparator) {
- let oldParts = oldValue.components(separatedBy: decimalSeparator)
- let newParts = newValue.components(separatedBy: decimalSeparator)
- // Check if we've removed the last digit after decimal point
- if oldParts.count > 1, newParts.count > 1,
- oldParts[1].count == 1, newParts[1].isEmpty
- {
- // Keep proper decimal format by adding trailing zero
- localText = newParts[0] + decimalSeparator + "0"
- if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
- text = decimal
- textDidChange?(decimal)
- }
- return
- }
- }
- }
- // Process normal text input changes
- handleTextChange(oldValue, newValue)
- }
- .onChange(of: text) { oldValue, newValue in
- // Handle external changes to the text binding
- // (changes not initiated by typing, like programmatic changes)
- if oldValue != newValue,
- Decimal(string: localText, locale: numberFormatter.locale) != newValue
- {
- if newValue == 0, isZeroCleared {
- // If value is zero and field was cleared, keep display empty to show placeholder
- localText = ""
- } else {
- // Otherwise format and display the new value
- localText = numberFormatter.string(from: newValue as NSNumber) ?? ""
- isZeroCleared = false
- }
- }
- }
- .onAppear {
- if text != 0 {
- // Initialize with formatted non-zero value
- localText = numberFormatter.string(from: text as NSNumber) ?? ""
- isZeroCleared = false
- } else {
- // For zero values, start with empty field to show placeholder
- localText = ""
- isZeroCleared = true
- }
- // Set initial focus if requested
- isFocused = initialFocus
- }
- }
- private func handleTextChange(_ oldValue: String, _ newValue: String) {
- // Handle empty input (clear operation)
- if newValue.isEmpty {
- text = 0
- isZeroCleared = true
- textDidChange?(0)
- return
- }
- // Remove leading zeros except for decimal values (e.g., "0.5")
- // This prevents inputs like "01", "0123", etc. but allows "0.5"
- if newValue.count > 1 && newValue.hasPrefix("0") && !newValue.hasPrefix("0" + (numberFormatter.decimalSeparator ?? ".")) {
- localText = String(newValue.dropFirst())
- return
- }
- let currentDecimalSeparator = numberFormatter.decimalSeparator ?? "."
- // Ensure there's only one decimal separator
- let decimalSeparatorCount = newValue.filter { String($0) == currentDecimalSeparator }.count
- if decimalSeparatorCount > 1 {
- // Reject input with multiple decimal separators
- localText = oldValue
- return
- }
- // Handle localization by converting to the correct decimal separator
- var processedText = newValue
- if newValue.contains("."), currentDecimalSeparator != "." {
- processedText = newValue.replacingOccurrences(of: ".", with: currentDecimalSeparator)
- } else if newValue.contains(","), currentDecimalSeparator != "," {
- processedText = newValue.replacingOccurrences(of: ",", with: currentDecimalSeparator)
- }
- // Automatically add leading zero when starting with decimal separator
- // For example ".5" becomes "0.5"
- if processedText.hasPrefix(currentDecimalSeparator) {
- processedText = "0" + processedText
- }
- // Validate against number formatter digit limits
- let components = processedText.components(separatedBy: currentDecimalSeparator)
- // Process the integer part (before decimal)
- var integerPart = components[0].filter { $0.isNumber }
- // Remove leading zeros for accurate digit counting
- while integerPart.hasPrefix("0") && integerPart.count > 1 {
- integerPart.removeFirst()
- }
- let integerDigits = integerPart.count
- // Count fraction digits (after decimal separator)
- let fractionDigits = components.count > 1 ? components[1].filter { $0.isNumber }.count : 0
- // Validate against the formatter's digit limits
- if integerDigits > numberFormatter.maximumIntegerDigits ||
- (allowDecimalSeparator && fractionDigits > numberFormatter.maximumFractionDigits)
- {
- // Reject input that exceeds digit limits
- localText = oldValue
- return
- }
- // Parse and validate the decimal value
- if let decimal = Decimal(string: processedText, locale: numberFormatter.locale) {
- if let maxValue = maxValue, decimal > maxValue {
- // Cap at maximum allowed value
- text = maxValue
- localText = numberFormatter.string(from: maxValue as NSNumber) ?? ""
- isZeroCleared = false
- } else {
- // Accept valid input and update binding
- text = decimal
- // Update zero-cleared state based on the value
- isZeroCleared = (decimal == 0) && localText.isEmpty
- textDidChange?(decimal)
- // If we had to process/modify the input, update the displayed text
- if processedText != newValue {
- localText = processedText
- }
- }
- } else {
- // Reject invalid decimal inputs
- localText = oldValue
- }
- }
- }
- extension UITextField {
- func moveCursorToEnd() {
- dispatchPrecondition(condition: .onQueue(.main))
- let newPosition = endOfDocument
- selectedTextRange = textRange(from: newPosition, to: newPosition)
- }
- }
- extension UIApplication {
- @objc func endEditing() {
- sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
- }
- }
- public struct TextFieldWithToolBarString: UIViewRepresentable {
- @Binding var text: String
- var placeholder: String
- var textAlignment: NSTextAlignment = .right
- var keyboardType: UIKeyboardType = .default
- var autocapitalizationType: UITextAutocapitalizationType = .none
- var autocorrectionType: UITextAutocorrectionType = .no
- var shouldBecomeFirstResponder: Bool = false
- var maxLength: Int? = nil
- var isDismissible: Bool = true
- public func makeUIView(context: Context) -> UITextField {
- let textField = UITextField()
- context.coordinator.textField = textField
- textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
- textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
- textField.delegate = context.coordinator
- textField.text = text
- textField.placeholder = placeholder
- textField.textAlignment = textAlignment
- textField.keyboardType = keyboardType
- textField.autocapitalizationType = autocapitalizationType
- textField.autocorrectionType = autocorrectionType
- textField.adjustsFontSizeToFitWidth = true
- return textField
- }
- /// Creates and configures a toolbar for the text field with clear and dismiss buttons.
- /// - Parameters:
- /// - textField: The text field for which the toolbar is being created.
- /// - context: The SwiftUI context that contains the coordinator for handling button actions.
- /// - Returns: A configured UIToolbar with clear and dismiss buttons.
- private func createToolbar(for textField: UITextField, context: Context) -> UIToolbar {
- let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
- let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
- let doneButton = UIBarButtonItem(
- image: UIImage(systemName: "keyboard.chevron.compact.down"),
- style: .done,
- target: textField,
- action: #selector(UITextField.resignFirstResponder)
- )
- let clearButton = UIBarButtonItem(
- image: UIImage(systemName: "trash"),
- style: .plain,
- target: context.coordinator,
- action: #selector(Coordinator.clearText)
- )
- toolbar.items = [clearButton, flexibleSpace, doneButton]
- toolbar.sizeToFit()
- return toolbar
- }
- public func updateUIView(_ textField: UITextField, context: Context) {
- if textField.text != text {
- textField.text = text
- }
- textField.textAlignment = textAlignment
- textField.keyboardType = keyboardType
- textField.autocapitalizationType = autocapitalizationType
- textField.autocorrectionType = autocorrectionType
- if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
- if textField.window != nil, textField.becomeFirstResponder() {
- context.coordinator.didBecomeFirstResponder = true
- }
- } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
- context.coordinator.didBecomeFirstResponder = false
- }
- }
- public func makeCoordinator() -> Coordinator {
- Coordinator(self, maxLength: maxLength)
- }
- public final class Coordinator: NSObject {
- var parent: TextFieldWithToolBarString
- var textField: UITextField?
- let maxLength: Int?
- var didBecomeFirstResponder = false
- init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
- self.parent = parent
- self.maxLength = maxLength
- }
- @objc fileprivate func clearText() {
- parent.text = ""
- textField?.text = ""
- }
- @objc fileprivate func editingDidBegin(_ textField: UITextField) {
- DispatchQueue.main.async {
- textField.moveCursorToEnd()
- }
- }
- }
- }
- extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
- public func textField(
- _ textField: UITextField,
- shouldChangeCharactersIn range: NSRange,
- replacementString string: String
- ) -> Bool {
- guard let currentText = textField.text as NSString? else {
- return false
- }
- // Calculate the new text length
- let newLength = currentText.length + string.count - range.length
- // If there's a maxLength, ensure the new length is within the limit
- if let maxLength = parent.maxLength, newLength > maxLength {
- return false
- }
- // Attempt to replace characters in range with the replacement string
- let newText = currentText.replacingCharacters(in: range, with: string)
- // Update the binding text state
- DispatchQueue.main.async {
- self.parent.text = newText
- }
- return true
- }
- }
|