taler-ios

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

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 }