|
|
@@ -15,6 +15,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
var textFieldDidBeginEditing: (() -> Void)?
|
|
|
var numberFormatter: NumberFormatter
|
|
|
var allowDecimalSeparator: Bool
|
|
|
+ var showArrows: Bool
|
|
|
var previousTextField: (() -> Void)?
|
|
|
var nextTextField: (() -> Void)?
|
|
|
|
|
|
@@ -32,6 +33,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
textFieldDidBeginEditing: (() -> Void)? = nil,
|
|
|
numberFormatter: NumberFormatter,
|
|
|
allowDecimalSeparator: Bool = true,
|
|
|
+ showArrows: Bool = false,
|
|
|
previousTextField: (() -> Void)? = nil,
|
|
|
nextTextField: (() -> Void)? = nil
|
|
|
) {
|
|
|
@@ -49,6 +51,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
self.numberFormatter = numberFormatter
|
|
|
self.numberFormatter.numberStyle = .decimal
|
|
|
self.allowDecimalSeparator = allowDecimalSeparator
|
|
|
+ self.showArrows = showArrows
|
|
|
self.previousTextField = previousTextField
|
|
|
self.nextTextField = nextTextField
|
|
|
}
|
|
|
@@ -56,7 +59,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
public func makeUIView(context: Context) -> UITextField {
|
|
|
let textField = UITextField()
|
|
|
context.coordinator.textField = textField
|
|
|
- textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
|
|
|
+ textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
|
|
|
textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
|
|
|
textField.delegate = context.coordinator
|
|
|
if text == 0 { /// show no value initially, i.e. empty String
|
|
|
@@ -68,36 +71,63 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
return textField
|
|
|
}
|
|
|
|
|
|
- private func makeDoneToolbar(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)
|
|
|
- )
|
|
|
- let previousButton = UIBarButtonItem(
|
|
|
- image: UIImage(systemName: "chevron.up"),
|
|
|
- style: .plain,
|
|
|
- target: context.coordinator,
|
|
|
- action: #selector(Coordinator.previousTextField)
|
|
|
- )
|
|
|
- let nextButton = UIBarButtonItem(
|
|
|
- image: UIImage(systemName: "chevron.down"),
|
|
|
- style: .plain,
|
|
|
- target: context.coordinator,
|
|
|
- action: #selector(Coordinator.nextTextField)
|
|
|
+ /// Creates and configures a toolbar for the text field with navigation and action buttons.
|
|
|
+ /// - Parameters:
|
|
|
+ /// - _: The text field for which the toolbar is being created (unused parameter).
|
|
|
+ /// - context: The SwiftUI context that contains the coordinator for handling button actions.
|
|
|
+ /// - Returns: A configured UIToolbar with appropriate buttons based on the view's configuration.
|
|
|
+ private func createToolbar(for _: UITextField, context: Context) -> UIToolbar {
|
|
|
+ let toolbar = UIToolbar()
|
|
|
+ var items: [UIBarButtonItem] = []
|
|
|
+
|
|
|
+ // Add navigation arrows if enabled
|
|
|
+ if showArrows {
|
|
|
+ // Add clear button
|
|
|
+ items.append(
|
|
|
+ UIBarButtonItem(
|
|
|
+ image: UIImage(systemName: "trash"),
|
|
|
+ style: .plain,
|
|
|
+ target: context.coordinator,
|
|
|
+ action: #selector(Coordinator.clearText)
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ if previousTextField != nil {
|
|
|
+ let previousButton = UIBarButtonItem(
|
|
|
+ image: UIImage(systemName: "chevron.up"),
|
|
|
+ style: .plain,
|
|
|
+ target: context.coordinator,
|
|
|
+ action: #selector(Coordinator.previousTextField)
|
|
|
+ )
|
|
|
+ items.append(previousButton)
|
|
|
+ }
|
|
|
+
|
|
|
+ if nextTextField != nil {
|
|
|
+ let nextButton = UIBarButtonItem(
|
|
|
+ image: UIImage(systemName: "chevron.down"),
|
|
|
+ style: .plain,
|
|
|
+ target: context.coordinator,
|
|
|
+ action: #selector(Coordinator.nextTextField)
|
|
|
+ )
|
|
|
+ items.append(nextButton)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add flexible space
|
|
|
+ items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
|
|
|
+
|
|
|
+ // Add done button
|
|
|
+ items.append(
|
|
|
+ UIBarButtonItem(
|
|
|
+ barButtonSystemItem: .done,
|
|
|
+ target: UIApplication.shared,
|
|
|
+ action: #selector(UIApplication.endEditing)
|
|
|
+ )
|
|
|
)
|
|
|
|
|
|
- toolbar.items = [clearButton, previousButton, nextButton, flexibleSpace, doneButton]
|
|
|
+ toolbar.items = items
|
|
|
toolbar.sizeToFit()
|
|
|
+
|
|
|
return toolbar
|
|
|
}
|
|
|
|
|
|
@@ -185,6 +215,16 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
|
|
|
}
|
|
|
|
|
|
extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
|
|
|
+ public func textFieldDidEndEditing(_ textField: UITextField) {
|
|
|
+ if let text = textField.text,
|
|
|
+ let decimal = Decimal(string: text, locale: parent.numberFormatter.locale)
|
|
|
+ {
|
|
|
+ // Format the number properly when editing ends
|
|
|
+ textField.text = parent.numberFormatter.string(from: decimal as NSNumber)
|
|
|
+ parent.text = decimal
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
public func textField(
|
|
|
_ textField: UITextField,
|
|
|
shouldChangeCharactersIn range: NSRange,
|
|
|
@@ -192,52 +232,57 @@ extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
|
|
|
) -> Bool {
|
|
|
// Check if the input is a number or the decimal separator
|
|
|
let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
|
|
|
- let isDecimalSeparator = (string == decimalFormatter.decimalSeparator && textField.text?.contains(string) == false)
|
|
|
|
|
|
- // Only proceed if the input is a valid number or decimal separator
|
|
|
- if isNumber || isDecimalSeparator && parent.allowDecimalSeparator,
|
|
|
+ // Get the current locale's decimal separator
|
|
|
+ let currentDecimalSeparator = parent.numberFormatter.decimalSeparator ?? "."
|
|
|
+
|
|
|
+ // Check if input is a decimal separator (either . or ,)
|
|
|
+ let isInputDecimalSeparator = string == "." || string == ","
|
|
|
+
|
|
|
+ // Only allow the decimal separator configured in the locale
|
|
|
+ if isInputDecimalSeparator {
|
|
|
+ // If it's not the correct decimal separator for this locale, reject it
|
|
|
+ if string != currentDecimalSeparator {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ // Check if the field already contains a decimal separator
|
|
|
+ if textField.text?.contains(currentDecimalSeparator) == true {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Only proceed if the input is a valid number or the correct decimal separator
|
|
|
+ if isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator),
|
|
|
let currentText = textField.text as NSString?
|
|
|
{
|
|
|
- // Get the proposed new text
|
|
|
- let proposedTextOriginal = currentText.replacingCharacters(in: range, with: string)
|
|
|
-
|
|
|
- // Remove thousand separator
|
|
|
- let proposedText = proposedTextOriginal.replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")
|
|
|
+ // Calculate the new text length
|
|
|
+ let newLength = currentText.length + string.count - range.length
|
|
|
|
|
|
- // Try to convert proposed text to number
|
|
|
- let number = parent.numberFormatter.number(from: proposedText) ?? decimalFormatter.number(from: proposedText)
|
|
|
+ // Check max length if specified
|
|
|
+ if let maxLength = parent.maxLength, newLength > maxLength {
|
|
|
+ return false
|
|
|
+ }
|
|
|
|
|
|
- let decimalPlacesCurrent = calculateDecimalPlaces(in: currentText as String)
|
|
|
- let maxDecimalPlaces = parent.numberFormatter.maximumFractionDigits
|
|
|
- let isCursorAfterDecimal = isCursorAfterDecimal(in: textField, range: range)
|
|
|
+ // Create the new text string
|
|
|
+ let newText = currentText.replacingCharacters(in: range, with: string)
|
|
|
|
|
|
- if decimalPlacesCurrent >= maxDecimalPlaces,
|
|
|
- range.length == 0,
|
|
|
- isCursorAfterDecimal
|
|
|
- {
|
|
|
+ // If text starts with decimal separator, add leading zero
|
|
|
+ if newText.hasPrefix(currentDecimalSeparator) {
|
|
|
+ textField.text = "0" + newText
|
|
|
+ parent.text = Decimal(string: textField.text ?? "0") ?? 0
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
- // Update the binding value if conversion is successful
|
|
|
- if let number = number {
|
|
|
- let lastCharIndex = proposedText.index(before: proposedText.endIndex)
|
|
|
- let hasDecimalSeparator = proposedText.contains(decimalFormatter.decimalSeparator)
|
|
|
- let hasTrailingZeros = (hasDecimalSeparator && proposedText[lastCharIndex] == "0") || isDecimalSeparator
|
|
|
- if !hasTrailingZeros
|
|
|
- {
|
|
|
- DispatchQueue.main.async {
|
|
|
- self.parent.text = number.decimalValue
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- DispatchQueue.main.async {
|
|
|
- self.parent.text = 0
|
|
|
- }
|
|
|
+ // Update the binding
|
|
|
+ if let decimal = Decimal(string: newText, locale: parent.numberFormatter.locale) {
|
|
|
+ parent.text = decimal
|
|
|
}
|
|
|
+
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
- // Allow the change if it's a valid number or decimal separator
|
|
|
- return isNumber || isDecimalSeparator && parent.allowDecimalSeparator
|
|
|
+ // Allow the change if it's a valid number or the correct decimal separator
|
|
|
+ return isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator)
|
|
|
}
|
|
|
|
|
|
public func textFieldDidBeginEditing(_: UITextField) {
|
|
|
@@ -254,7 +299,7 @@ extension UITextField {
|
|
|
}
|
|
|
|
|
|
extension UIApplication {
|
|
|
- func endEditing() {
|
|
|
+ @objc func endEditing() {
|
|
|
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
|
}
|
|
|
}
|
|
|
@@ -273,7 +318,7 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
|
|
|
public func makeUIView(context: Context) -> UITextField {
|
|
|
let textField = UITextField()
|
|
|
context.coordinator.textField = textField
|
|
|
- textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
|
|
|
+ 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
|
|
|
@@ -286,7 +331,12 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
|
|
|
return textField
|
|
|
}
|
|
|
|
|
|
- private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
|
|
|
+ /// 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(
|