CurrencyField.swift (11174B)
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>) { 51 self._amount = amount 52 self.currencyInfo = currencyInfo 53 self.currencyFieldRepresentable = 54 CurrencyTextfieldRepresentable(currencyInfo: self.currencyInfo, 55 amount: self.$amount) 56 } 57 58 var body: some View { 59 #if PRINT_CHANGES 60 let _ = Self._printChanges() 61 let _ = symLog.vlog(amount.description) // just to get the # to compare it with .onAppear & onDisappear 62 #endif 63 ZStack { 64 // Text view to display the formatted currency 65 // Set as priority so CurrencyInputField size doesn't affect parent 66 let formatted = amount.formatted(currencyInfo, isNegative: false) 67 let text = Text(formatted.0) 68 .accessibilityLabel(formatted.1) 69 .layoutPriority(1) 70 // make the textfield use the whole width for tapping inside to become active 71 .frame(maxWidth: .infinity, alignment: .trailing) 72 .padding(4) 73 text 74 .accessibilityHidden(true) 75 .background(WalletColors().fieldBackground) 76 .cornerRadius(10) 77 .overlay(RoundedRectangle(cornerRadius: 10) 78 .stroke(WalletColors().fieldForeground, lineWidth: 1)) 79 // Input text field to handle UI 80 currencyFieldRepresentable 81 } 82 } 83 } 84 // MARK: - 85 // Sub-class UITextField to remove selection and caret 86 class NoCaretTextField: UITextField { 87 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 88 false 89 } 90 91 override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { 92 [] 93 } 94 95 override func caretRect(for position: UITextPosition) -> CGRect { 96 .null 97 } 98 } 99 // MARK: - 100 @MainActor 101 struct CurrencyTextfieldRepresentable: UIViewRepresentable { 102 let currencyInfo: CurrencyInfo 103 @Binding var amount: Amount 104 105 private let textField = NoCaretTextField(frame: .zero) 106 107 func makeCoordinator() -> Coordinator { 108 Coordinator(self) 109 } 110 111 @MainActor public func becomeFirstResponder() -> Bool { 112 textField.becomeFirstResponder() 113 } 114 115 @MainActor public func resignFirstResponder() { 116 textField.resignFirstResponder() 117 Self.endEditing() 118 } 119 120 func updateText(amount: Amount) { 121 let plain = amount.plainString(currencyInfo) 122 // print("Setting textfield to: \(plain)") 123 textField.text = plain 124 let endPosition = textField.endOfDocument 125 textField.selectedTextRange = textField.textRange(from: endPosition, to: endPosition) 126 } 127 128 func toolBar() -> UIToolbar { 129 let image = UIImage(systemName: "return") 130 let button = UIBarButtonItem(image: image, style: .done, target: textField, 131 action: #selector(UITextField.resignFirstResponder)) 132 let flexSpace = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, 133 target: self, action: nil) 134 let toolBar: UIToolbar = UIToolbar() 135 toolBar.items = [flexSpace, button] 136 137 // Unable to simultaneously satisfy constraints 138 // Will attempt to recover by breaking constraint 139 // <NSLayoutConstraint: UIImageView: .centerY == _UIModernBarButton: .centerY (active)> 140 141 // this all doesn't help 142 // toolBar.frame.size.height = 100 143 // toolBar.autoresizingMask = .flexibleWidth 144 // toolBar.translatesAutoresizingMaskIntoConstraints = false 145 toolBar.sizeToFit() 146 return toolBar 147 } 148 149 func makeUIView(context: Context) -> NoCaretTextField { 150 textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 151 152 // Assign delegate 153 textField.delegate = context.coordinator 154 155 // Set keyboard type 156 textField.keyboardType = .asciiCapableNumberPad // numberPad decimalPad phonePad numbersAndPunctuation 157 158 // Make visual components invisible... 159 textField.tintColor = .clear 160 textField.textColor = .clear 161 textField.backgroundColor = .clear 162 // ... except for the bezel around the textfield 163 textField.borderStyle = .none // .roundedRect 164 // textField.textFieldStyle(.roundedBorder) 165 166 #if DEBUG 167 // Debugging: add a red border around the textfield 168 let myColor = UIColor(red: 0.9, green: 0.1, blue:0, alpha: 1.0) 169 textField.layer.masksToBounds = true 170 textField.layer.borderColor = myColor.cgColor 171 // textField.layer.borderWidth = 2.0 // <- uncomment to show the border 172 #endif 173 // Add editingChanged event handler 174 textField.addTarget( 175 context.coordinator, 176 action: #selector(Coordinator.editingChanged(textField:)), 177 for: .editingChanged 178 ) 179 180 // Add a toolbar with a done button above the keyboard 181 textField.inputAccessoryView = toolBar() 182 183 // Set initial textfield text 184 context.coordinator.updateText(amount, textField: textField) 185 186 return textField 187 } 188 189 func updateUIView(_ uiView: NoCaretTextField, context: Context) {} 190 191 class Coordinator: NSObject, UITextFieldDelegate { 192 // Reference to currency input field 193 private var textfieldRepresentable: CurrencyTextfieldRepresentable 194 195 // Last valid text input string to be displayed 196 private var lastValidInput: String? = EMPTYSTRING 197 198 init(_ representable: CurrencyTextfieldRepresentable) { 199 self.textfieldRepresentable = representable 200 } 201 202 func setValue(_ amount: Amount, textField: UITextField) { 203 // Update hidden textfield text 204 updateText(amount, textField: textField) 205 // Update input value 206 // print(input.amount.description, " := ", amount.description) 207 textfieldRepresentable.amount = amount 208 } 209 210 func updateText(_ amount: Amount, textField: UITextField) { 211 // Update field text and last valid input text 212 lastValidInput = amount.plainString(textfieldRepresentable.currencyInfo) 213 // print("lastValidInput: `\(lastValidInput)´") 214 textField.text = lastValidInput 215 let endPosition = textField.endOfDocument 216 textField.selectedTextRange = textField.textRange(from: endPosition, to: endPosition) 217 } 218 219 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 220 // If replacement string is empty, we can assume the backspace key was hit 221 if string.isEmpty { 222 // Resign first responder when delete is hit when value is 0 223 if textfieldRepresentable.amount.isZero { 224 textField.resignFirstResponder() 225 // Self.endEditing() 226 } else { 227 // Remove trailing digit: divide value by 10 228 let amount = textfieldRepresentable.amount.copy() 229 amount.removeDigit(textfieldRepresentable.currencyInfo) 230 setValue(amount, textField: textField) 231 } 232 } 233 return true 234 } 235 236 func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { 237 return true 238 } 239 240 func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { 241 return true 242 } 243 244 @objc func editingChanged(textField: NoCaretTextField) { 245 // Get a mutable copy of last text 246 guard var oldText = lastValidInput else { 247 return 248 } 249 250 // Iterate through each char of the new string and compare LTR with old string 251 let char = (textField.text ?? EMPTYSTRING).first { next in 252 // If old text is empty or its next character doesn't match new 253 if oldText.isEmpty || next != oldText.removeFirst() { 254 // Found the mismatching character 255 return true 256 } 257 return false 258 } 259 260 // Find new character and try to get an Int value from it 261 guard let char, let digit = UInt8(String(char)), digit <= 9 else { 262 // New character could not be converted to Int 263 // Revert to last valid text 264 textField.text = lastValidInput 265 return 266 } 267 268 // Multiply by 10 to shift numbers one position to the left, revert if an overflow occurs 269 // Add the new trailing digit, revert if an overflow occurs 270 let amount = textfieldRepresentable.amount.copy() 271 amount.addDigit(digit, currencyInfo: textfieldRepresentable.currencyInfo) 272 273 // If new value has more digits than allowed by formatter, revert 274 // if input.formatter.maximumFractionDigits + input.formatter.maximumIntegerDigits < String(addValue).count { 275 // textField.text = lastValidInput 276 // return 277 // } 278 279 // Update new value 280 setValue(amount, textField: textField) 281 } 282 } 283 }