commit b303592f6c1c36efa37929ac6382c9bc043d9331
parent f4cd8b85ce8e6c354cad90fb12b82eed53321bf8
Author: Marc Stibane <marc@taler.net>
Date: Fri, 14 Aug 2026 17:53:00 +0200
AI: CurrencyFieldHandle
Diffstat:
2 files changed, 62 insertions(+), 33 deletions(-)
diff --git a/TalerWallet1/Views/HelperViews/CurrencyField.swift b/TalerWallet1/Views/HelperViews/CurrencyField.swift
@@ -47,12 +47,13 @@ struct CurrencyField: View {
currencyFieldRepresentable.updateText(amount: amount)
}
- public init(_ currencyInfo: CurrencyInfo, amount: Binding<Amount>) {
+ public init(_ currencyInfo: CurrencyInfo, amount: Binding<Amount>, handle: CurrencyFieldHandle) {
self._amount = amount
self.currencyInfo = currencyInfo
self.currencyFieldRepresentable =
CurrencyTextfieldRepresentable(currencyInfo: self.currencyInfo,
- amount: self.$amount)
+ amount: self.$amount,
+ handle: handle)
}
var body: some View {
@@ -84,6 +85,20 @@ struct CurrencyField: View {
// MARK: -
// Sub-class UITextField to remove selection and caret
class NoCaretTextField: UITextField {
+ /// The text as it was last set from an `Amount´. `Coordinator.editingChanged´ diffs
+ /// the field's text against it to find the newly typed digit, so the two must never
+ /// be set independently - always use `setPlainText´ for both.
+ fileprivate var lastValidInput: String? = EMPTYSTRING
+
+ /// Set both the displayed text and the last valid input, caret at the end.
+ fileprivate func setPlainText(_ plain: String?) {
+ lastValidInput = plain
+// print("Setting textfield to: \(plain)")
+ text = plain
+ let endPosition = endOfDocument
+ selectedTextRange = textRange(from: endPosition, to: endPosition)
+ }
+
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
false
}
@@ -97,35 +112,39 @@ class NoCaretTextField: UITextField {
}
}
// MARK: -
+/// SwiftUI re-creates `CurrencyField´ and its `UIViewRepresentable´ on every body pass,
+/// but calls `makeUIView´ only once. The view which shows the field keeps this box in
+/// `@State´, so it survives those passes and always refers to the text field which
+/// really is in the view hierarchy.
+final class CurrencyFieldHandle {
+ fileprivate weak var textField: NoCaretTextField? = nil
+}
+// MARK: -
@MainActor
struct CurrencyTextfieldRepresentable: UIViewRepresentable {
let currencyInfo: CurrencyInfo
@Binding var amount: Amount
-
- private let textField = NoCaretTextField(frame: .zero)
+ let handle: CurrencyFieldHandle
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
@MainActor public func becomeFirstResponder() -> Bool {
- textField.becomeFirstResponder()
+ guard let textField = handle.textField else { return false }
+ return textField.becomeFirstResponder()
}
@MainActor public func resignFirstResponder() {
- textField.resignFirstResponder()
+ handle.textField?.resignFirstResponder()
Self.endEditing()
}
func updateText(amount: Amount) {
- let plain = amount.plainString(currencyInfo)
-// print("Setting textfield to: \(plain)")
- textField.text = plain
- let endPosition = textField.endOfDocument
- textField.selectedTextRange = textField.textRange(from: endPosition, to: endPosition)
+ handle.textField?.setPlainText(amount.plainString(currencyInfo))
}
- func toolBar() -> UIToolbar {
+ func toolBar(for textField: UITextField) -> UIToolbar {
let image = UIImage(systemName: RETURN) //
let button = UIBarButtonItem(image: image, style: .done, target: textField,
action: #selector(UITextField.resignFirstResponder))
@@ -147,6 +166,9 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
}
func makeUIView(context: Context) -> NoCaretTextField {
+ // the view hierarchy owns the field, `handle´ only points to it
+ let textField = NoCaretTextField(frame: .zero)
+ handle.textField = textField
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// Assign delegate
@@ -178,42 +200,45 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
)
// Add a toolbar with a done button above the keyboard
- textField.inputAccessoryView = toolBar()
+ textField.inputAccessoryView = toolBar(for: textField)
// Set initial textfield text
- context.coordinator.updateText(amount, textField: textField)
+ textField.setPlainText(amount.plainString(currencyInfo))
return textField
}
- func updateUIView(_ uiView: NoCaretTextField, context: Context) {}
+ func updateUIView(_ uiView: NoCaretTextField, context: Context) {
+ // the coordinator captured an older copy of this struct - refresh it
+ context.coordinator.textfieldRepresentable = self
+ // reconcile the field with the `value´, e.g. after a shortcut button was tapped
+ let plain = amount.plainString(currencyInfo)
+ if uiView.lastValidInput != plain {
+ uiView.setPlainText(plain)
+ }
+ }
class Coordinator: NSObject, UITextFieldDelegate {
// Reference to currency input field
- private var textfieldRepresentable: CurrencyTextfieldRepresentable
-
- // Last valid text input string to be displayed
- private var lastValidInput: String? = EMPTYSTRING
+ fileprivate var textfieldRepresentable: CurrencyTextfieldRepresentable
init(_ representable: CurrencyTextfieldRepresentable) {
self.textfieldRepresentable = representable
}
- func setValue(_ amount: Amount, textField: UITextField) {
+ func setValue(_ amount: Amount) {
// Update hidden textfield text
- updateText(amount, textField: textField)
+ updateText(amount)
// Update input value
// print(input.amount.description, " := ", amount.description)
textfieldRepresentable.amount = amount
}
- func updateText(_ amount: Amount, textField: UITextField) {
+ func updateText(_ amount: Amount) {
// Update field text and last valid input text
- lastValidInput = amount.plainString(textfieldRepresentable.currencyInfo)
-// print("lastValidInput: `\(lastValidInput)´")
- textField.text = lastValidInput
- let endPosition = textField.endOfDocument
- textField.selectedTextRange = textField.textRange(from: endPosition, to: endPosition)
+ let plain = amount.plainString(textfieldRepresentable.currencyInfo)
+// print("lastValidInput: `\(plain)´")
+ textfieldRepresentable.handle.textField?.setPlainText(plain)
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
@@ -227,7 +252,7 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
// Remove trailing digit: divide value by 10
let amount = textfieldRepresentable.amount.copy()
amount.removeDigit(textfieldRepresentable.currencyInfo)
- setValue(amount, textField: textField)
+ setValue(amount)
}
}
return true
@@ -242,8 +267,9 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
}
@objc func editingChanged(textField: NoCaretTextField) {
- // Get a mutable copy of last text
- guard var oldText = lastValidInput else {
+ // Get a mutable copy of last text - from the field which sent this event,
+ // so that it can never be out of sync with the text we compare it against
+ guard var oldText = textField.lastValidInput else {
return
}
@@ -261,7 +287,7 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
guard let char, let digit = UInt8(String(char)), digit <= 9 else {
// New character could not be converted to Int
// Revert to last valid text
- textField.text = lastValidInput
+ textField.text = textField.lastValidInput
return
}
@@ -277,7 +303,7 @@ struct CurrencyTextfieldRepresentable: UIViewRepresentable {
// }
// Update new value
- setValue(amount, textField: textField)
+ setValue(amount)
}
}
}
diff --git a/TalerWallet1/Views/HelperViews/CurrencyInputView.swift b/TalerWallet1/Views/HelperViews/CurrencyInputView.swift
@@ -69,6 +69,9 @@ struct CurrencyInputView: View {
@State private var hasBeenShown = false
@State private var useShortcut = 0
+ // `body´ builds a new CurrencyField each time, but the text field is created only
+ // once - keep the handle to it here, where it survives the re-evaluations
+ @State private var fieldHandle = CurrencyFieldHandle()
@MainActor
func action(shortcut: Int, currencyField: CurrencyField) {
@@ -150,7 +153,7 @@ struct CurrencyInputView: View {
// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear
#endif
let currencyInfo = currencyInfo()
- let currencyField = CurrencyField(currencyInfo, amount: $amount)
+ let currencyField = CurrencyField(currencyInfo, amount: $amount, handle: fieldHandle)
VStack (alignment: .center) { // center shortcut buttons
if let heading = heading() {
Text(heading.0)