taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }