Swift format input as percentage in "as-you-type" fashion

Viewed 301

I have a text field that calls a method to format the input as a percentage in an as-you-type fashion.

cell.textField.addTarget(self, action: #selector(textChangedIntoPercent(sender:)), for: .editingChanged)

The method called:

@objc func textChangedIntoPercent (sender : UITextField) {
    
    if let amountString = sender.text?.percentInputFormatting() {
        sender.text = amountString
    }
}

The following code works well when entering numbers with the trailing % sign. However, when using backspace, nothing is deleted, the string just returns itself, and the last number is not backspaced. If I change the position of the cursor to just before the percent sign, I am able to delete the numbers.

func percentInputFormatting() -> String {
 
    var number: NSNumber!
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    formatter.maximumFractionDigits = 2
    formatter.minimumFractionDigits = 2
    
    var amountWithPrefix = self
    
    let regex = try! NSRegularExpression(pattern: "[^0-9]", options: .caseInsensitive)
    amountWithPrefix = regex.stringByReplacingMatches(in: amountWithPrefix, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSMakeRange(0, self.count), withTemplate: "")
    
    let double = (amountWithPrefix as NSString).doubleValue
    number = NSNumber(value: (double / 100))

    guard number != 0 as NSNumber else {
        return "0%"
    }
    
    return "\(formatter.string(from: number)!)" + "%"
}
1 Answers

Use the shouldChangeCharactersIn delegate method to detect backspaces. In fact, I recommend that you implement the entire thing in this delegate method, rather than the action for editingChanged.

First, set the delegate if you haven't already:

cell.textField.delegate = self

...

Now implement shouldChangeCharactersIn. This method will be called when user edits the text field in some way, but before the text field's text actually changes. You can get what change the the user is trying to do from the parameters range and string. The user is trying to replace the range "range" in textField.text with the string in string.

If string is empty, that means the user is trying to delete stuff. We drop the last digit, and format the number again. Otherwise, apply the user's edits using replacingCharacters(in: range, with: string), and check if it is a valid number. If it is, format it.

We are supposed to return a Bool indicating whether the edit should be applied or not. In this case, since how the text field's text should change is 100% handled by us, we don't want to apply the user's edits.

extension YourViewController: UITextFieldDelegate {
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        guard let oldText = textField.text else { return false }

        if string.isEmpty { // this means that backspace is pressed
            let oldDigits = textField.text?.digits
            // drops one digit every time backspace is pressed
            let newNumber = Double(oldDigits.dropLast()) ?? 0
            textField.text = percentFormatter.string(for: newNumber / 10000)
        } else // something has been entered
            if let newNumber = Double(
                    (oldText as NSString).replacingCharacters(in: range, with: string).digits
                  ) {
            textField.text = percentFormatter.string(for: newNumber / 10000)
        }
        return false
    }
}

where percentFormatter and withoutNonDigits are:

let percentFormatter: NumberFormatter = {
    let f = NumberFormatter()
    f.minimumFractionDigits = 2
    f.maximumFractionDigits = 2
    f.numberStyle = .percent
    f.locale = Locale(identifier: "en-US_POSIX")
    return f
}()

extension String {
    var digits: String {
        filter { "0"..."9" ~= $0 }
    }
}
Related