CurrencyField.swift (12571B)
1 /* MIT License 2 * Copyright (c) 2022 Javier Trinchero 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a copy 5 * of this software and associated documentation files (the "Software"), to deal 6 * in the Software without restriction, including without limitation the rights 7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 * copies of the Software, and to permit persons to whom the Software is 9 * furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in all 12 * copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 * SOFTWARE. 21 */ 22 /** 23 * @author Marc Stibane 24 */ 25 import SwiftUI 26 import UIKit 27 import taler_swift 28 import SymLog 29 30 @MainActor 31 struct CurrencyField: View { 32 private let symLog = SymLogV(0) 33 let currencyInfo: CurrencyInfo 34 @Binding var amount: Amount // the `value´ 35 36 private var currencyFieldRepresentable: CurrencyTextfieldRepresentable! = nil 37 38 public func becomeFirstResponder() -> Bool { 39 currencyFieldRepresentable.becomeFirstResponder() 40 } 41 42 public func resignFirstResponder() -> Void { 43 currencyFieldRepresentable.resignFirstResponder() 44 } 45 46 func updateText(amount: Amount) { 47 currencyFieldRepresentable.updateText(amount: amount) 48 } 49 50 public init(_ currencyInfo: CurrencyInfo, amount: Binding<Amount>, handle: CurrencyFieldHandle) { 51 self._amount = amount 52 self.currencyInfo = currencyInfo 53 self.currencyFieldRepresentable = 54 CurrencyTextfieldRepresentable(currencyInfo: self.currencyInfo, 55 amount: self.$amount, 56 handle: handle) 57 } 58 59 var body: some View { 60 #if PRINT_CHANGES 61 let _ = Self._printChanges() 62 let _ = symLog.vlog(amount.description) // just to get the # to compare it with .onAppear & onDisappear 63 #endif 64 ZStack { 65 // Text view to display the formatted currency 66 // Set as priority so CurrencyInputField size doesn't affect parent 67 let formatted = amount.formatted(currencyInfo, isNegative: false) 68 let text = Text(formatted.0) 69 .accessibilityLabel(formatted.1) 70 .layoutPriority(1) 71 // make the textfield use the whole width for tapping inside to become active 72 .frame(maxWidth: .infinity, alignment: .trailing) 73 .padding(4) 74 text 75 .accessibilityHidden(true) 76 .background(WalletColors().fieldBackground) 77 .cornerRadius(10) 78 .overlay(RoundedRectangle(cornerRadius: 10) 79 .stroke(WalletColors().fieldForeground, lineWidth: 1)) 80 // Input text field to handle UI 81 currencyFieldRepresentable 82 } 83 } 84 } 85 // MARK: - 86 // Sub-class UITextField to remove selection and caret 87 class NoCaretTextField: UITextField { 88 /// The text as it was last set from an `Amount´. `Coordinator.editingChanged´ diffs 89 /// the field's text against it to find the newly typed digit, so the two must never 90 /// be set independently - always use `setPlainText´ for both. 91 fileprivate var lastValidInput: String? = EMPTYSTRING 92 93 /// Set both the displayed text and the last valid input, caret at the end. 94 fileprivate func setPlainText(_ plain: String?) { 95 lastValidInput = plain 96 // print("Setting textfield to: \(plain)") 97 text = plain 98 let endPosition = endOfDocument 99 selectedTextRange = textRange(from: endPosition, to: endPosition) 100 } 101 102 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 103 false 104 } 105 106 override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { 107 [] 108 } 109 110 override func caretRect(for position: UITextPosition) -> CGRect { 111 .null 112 } 113 } 114 // MARK: - 115 /// SwiftUI re-creates `CurrencyField´ and its `UIViewRepresentable´ on every body pass, 116 /// but calls `makeUIView´ only once. The view which shows the field keeps this box in 117 /// `@State´, so it survives those passes and always refers to the text field which 118 /// really is in the view hierarchy. 119 final class CurrencyFieldHandle { 120 fileprivate weak var textField: NoCaretTextField? = nil 121 } 122 // MARK: - 123 @MainActor 124 struct CurrencyTextfieldRepresentable: UIViewRepresentable { 125 let currencyInfo: CurrencyInfo 126 @Binding var amount: Amount 127 let handle: CurrencyFieldHandle 128 129 func makeCoordinator() -> Coordinator { 130 Coordinator(self) 131 } 132 133 @MainActor public func becomeFirstResponder() -> Bool { 134 guard let textField = handle.textField else { return false } 135 return textField.becomeFirstResponder() 136 } 137 138 @MainActor public func resignFirstResponder() { 139 handle.textField?.resignFirstResponder() 140 Self.endEditing() 141 } 142 143 func updateText(amount: Amount) { 144 handle.textField?.setPlainText(amount.plainString(currencyInfo)) 145 } 146 147 func toolBar(for textField: UITextField) -> UIToolbar { 148 let image = UIImage(systemName: RETURN) // 149 let button = UIBarButtonItem(image: image, style: .done, target: textField, 150 action: #selector(UITextField.resignFirstResponder)) 151 let flexSpace = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, 152 target: self, action: nil) 153 let toolBar: UIToolbar = UIToolbar() 154 toolBar.items = [flexSpace, button] 155 156 // Unable to simultaneously satisfy constraints 157 // Will attempt to recover by breaking constraint 158 // <NSLayoutConstraint: UIImageView: .centerY == _UIModernBarButton: .centerY (active)> 159 160 // this all doesn't help 161 // toolBar.frame.size.height = 100 162 // toolBar.autoresizingMask = .flexibleWidth 163 // toolBar.translatesAutoresizingMaskIntoConstraints = false 164 toolBar.sizeToFit() 165 return toolBar 166 } 167 168 func makeUIView(context: Context) -> NoCaretTextField { 169 // the view hierarchy owns the field, `handle´ only points to it 170 let textField = NoCaretTextField(frame: .zero) 171 handle.textField = textField 172 textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 173 174 // Assign delegate 175 textField.delegate = context.coordinator 176 177 // Set keyboard type 178 textField.keyboardType = .asciiCapableNumberPad // numberPad decimalPad phonePad numbersAndPunctuation 179 180 // Make visual components invisible... 181 textField.tintColor = .clear 182 textField.textColor = .clear 183 textField.backgroundColor = .clear 184 // ... except for the bezel around the textfield 185 textField.borderStyle = .none // .roundedRect 186 // textField.textFieldStyle(.roundedBorder) 187 188 #if DEBUG 189 // Debugging: add a red border around the textfield 190 let myColor = UIColor(red: 0.9, green: 0.1, blue:0, alpha: 1.0) 191 textField.layer.masksToBounds = true 192 textField.layer.borderColor = myColor.cgColor 193 // textField.layer.borderWidth = 2.0 // <- uncomment to show the border 194 #endif 195 // Add editingChanged event handler 196 textField.addTarget( 197 context.coordinator, 198 action: #selector(Coordinator.editingChanged(textField:)), 199 for: .editingChanged 200 ) 201 202 // Add a toolbar with a done button above the keyboard 203 textField.inputAccessoryView = toolBar(for: textField) 204 205 // Set initial textfield text 206 textField.setPlainText(amount.plainString(currencyInfo)) 207 208 return textField 209 } 210 211 func updateUIView(_ uiView: NoCaretTextField, context: Context) { 212 // the coordinator captured an older copy of this struct - refresh it 213 context.coordinator.textfieldRepresentable = self 214 // reconcile the field with the `value´, e.g. after a shortcut button was tapped 215 let plain = amount.plainString(currencyInfo) 216 if uiView.lastValidInput != plain { 217 uiView.setPlainText(plain) 218 } 219 } 220 221 class Coordinator: NSObject, UITextFieldDelegate { 222 // Reference to currency input field 223 fileprivate var textfieldRepresentable: CurrencyTextfieldRepresentable 224 225 init(_ representable: CurrencyTextfieldRepresentable) { 226 self.textfieldRepresentable = representable 227 } 228 229 func setValue(_ amount: Amount) { 230 // Update hidden textfield text 231 updateText(amount) 232 // Update input value 233 // print(input.amount.description, " := ", amount.description) 234 textfieldRepresentable.amount = amount 235 } 236 237 func updateText(_ amount: Amount) { 238 // Update field text and last valid input text 239 let plain = amount.plainString(textfieldRepresentable.currencyInfo) 240 // print("lastValidInput: `\(plain)´") 241 textfieldRepresentable.handle.textField?.setPlainText(plain) 242 } 243 244 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 245 // If replacement string is empty, we can assume the backspace key was hit 246 if string.isEmpty { 247 // Resign first responder when delete is hit when value is 0 248 if textfieldRepresentable.amount.isZero { 249 textField.resignFirstResponder() 250 // Self.endEditing() 251 } else { 252 // Remove trailing digit: divide value by 10 253 let amount = textfieldRepresentable.amount.copy() 254 amount.removeDigit(textfieldRepresentable.currencyInfo) 255 setValue(amount) 256 } 257 } 258 return true 259 } 260 261 func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { 262 return true 263 } 264 265 func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { 266 return true 267 } 268 269 @objc func editingChanged(textField: NoCaretTextField) { 270 // Get a mutable copy of last text - from the field which sent this event, 271 // so that it can never be out of sync with the text we compare it against 272 guard var oldText = textField.lastValidInput else { 273 return 274 } 275 276 // Iterate through each char of the new string and compare LTR with old string 277 let char = (textField.text ?? EMPTYSTRING).first { next in 278 // If old text is empty or its next character doesn't match new 279 if oldText.isEmpty || next != oldText.removeFirst() { 280 // Found the mismatching character 281 return true 282 } 283 return false 284 } 285 286 // Find new character and try to get an Int value from it 287 guard let char, let digit = UInt8(String(char)), digit <= 9 else { 288 // New character could not be converted to Int 289 // Revert to last valid text 290 textField.text = textField.lastValidInput 291 return 292 } 293 294 // Multiply by 10 to shift numbers one position to the left, revert if an overflow occurs 295 // Add the new trailing digit, revert if an overflow occurs 296 let amount = textfieldRepresentable.amount.copy() 297 amount.addDigit(digit, currencyInfo: textfieldRepresentable.currencyInfo) 298 299 // If new value has more digits than allowed by formatter, revert 300 // if input.formatter.maximumFractionDigits + input.formatter.maximumIntegerDigits < String(addValue).count { 301 // textField.text = lastValidInput 302 // return 303 // } 304 305 // Update new value 306 setValue(amount) 307 } 308 } 309 }