Currency TextField in SwiftUI

Between banking and crypto apps, it’s quite often we interact with currency inputs on daily basis. If creating a localized UITextField can already be tricky in UIKit, I was wondering how hard it would be to do a similar one in SwiftUI. Let’s see today how to create a localized currency TextField in SwiftUI.

This post is based on Xcode 12.5.1

Few days ago, I was using a local banking app and noticed their field to send money was quite nice. The field behaved like a payment terminal, typing from right to left and shifting digits along. That makes the experience really easy rather than just a free field prompt to validation error. I was wondering if something similar was doable in SwiftUI.

Here is the goal.

currency-textfield

Ideally the field would support localization and reformat the currency based on the region. Contrary to my first thoughts, it happened to be really simple to use TextField for a localized field.

SwiftUI TextField

With TextField, Apple made it really easy to pass a formatter. Lucky enough, NumberFormatter is one of them. It is a really handy one when it comes to manipulating numbers, and it supports currency.

import SwiftUI

struct ContentView: View {

  @State private var value = 0
  private let numberFormatter: NumberFormatter
    
    init() {
      numberFormatter = NumberFormatter()
      numberFormatter.numberStyle = .currency
      numberFormatter.maximumFractionDigits = 2
    }

    var body: some View {
        VStack() {

            TextField("$0.00", value: $value, formatter: numberFormatter)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .keyboardType(.numberPad)
        }
    }
}

So far so good!. The formatter does the heavy lifting deep down into the TextField.

However the experience isn’t completely there.

For once, it’s a bit blurry what is the placeholder or the default value used. Also, since the value includes the currency symbol, it is available to edition. It would be great to be ignored from the edition: the user would only change the amount without the possibility to remove the symbol.

The behaviors of the TextField is also a bit more limited that UIKit allows to. For instance, how to remove the cursor or the focus on conditions.

Finally, capturing the latest value isn’t straightforward either: in iOS15, Apple introduced onSubmit(of:_:) but we don’t have similar for previous versions.

It seems there is no choice but to bridge back from UIKit and expose it to SwiftUI.

Currency UITextField

First thing is to create a custom UITextField that will be exposed later on to SwiftUI. The goal is still to make it localizable so it should support NumberFormatter as well.

I also want to support a binding value to pass back like other SwiftUI TextField does.

import UIKit

class CurrencyUITextField: UITextField {
    
    @Binding private var value: Int
    private let formatter: NumberFormatter
    
    init(formatter: NumberFormatterProtocol, value: Binding<Int>) {
        self.formatter = formatter
        self._value = value
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

The rest of this custom view will be to style it and to manipulate it the value for an observable value.

The manipulation is multitude transformation from a type to another to get the expected behaviors of “pushing” number from the right, like a terminal would do:

  • For any new number tapped, we get get a Decimal representation from the value (ignoring the symbol)
  • Transforming to the right digit format based on the formatter and its number of fraction digits
  • Transforming back to a string with the symbol in it again.

The same way, when removing a digit, we want to shift all the others left to right.


import UIKit 
class CurrencyUITextField: UITextField {
    
    @Binding private var value: Int
    private let formatter: NumberFormatter
    
    init(formatter: NumberFormatter, value: Binding<Int>) {
        self.formatter = formatter
        self._value = value
        super.init(frame: .zero)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func willMove(toSuperview newSuperview: UIView?) {
        addTarget(self, action: #selector(editingChanged), for: .editingChanged)
        addTarget(self, action: #selector(resetSelection), for: .allTouchEvents)
        keyboardType = .numberPad
        textAlignment = .right
        sendActions(for: .editingChanged)
    }
    
    override func deleteBackward() {
        text = textValue.digits.dropLast().string
        sendActions(for: .editingChanged)
    }
    
    private func setupViews() {
        tintColor = .clear
        font = .systemFont(ofSize: 40, weight: .regular)
    }
    
    @objc private func editingChanged() {
        text = currency(from: decimal)
        resetSelection()
        value = Int(doubleValue * 100)
    }
    
    @objc private func resetSelection() {
        selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
    }
    
    private var textValue: String {
        return text ?? ""
    }

    private var doubleValue: Double { 
      return (decimal as NSDecimalNumber).doubleValue 
    }

    private var decimal: Decimal { 
      return textValue.decimal / pow(10, formatter.maximumFractionDigits) 
    }
    
    private func currency(from decimal: Decimal) -> String { 
        return formatter.string(for: decimal) ?? ""
    }
}

extension StringProtocol where Self: RangeReplaceableCollection {
    var digits: Self { filter (\.isWholeNumber) }
}

extension String {
    var decimal: Decimal { Decimal(string: digits) ?? 0 }
}

extension LosslessStringConvertible {
    var string: String { .init(self) }
}

The view is ready, I just need to bring it to SwiftUI.

import SwiftUI

struct CurrencyTextField: UIViewRepresentable {
    
    typealias UIViewType = CurrencyUITextField
    
    let numberFormatter: NumberFormatter
    let currencyField: CurrencyUITextField
    
    init(numberFormatter: NumberFormatter, value: Binding<Int>) {
        self.numberFormatter = numberFormatter 
        currencyField = CurrencyUITextField(formatter: numberFormatter, value: value)
    }
    
    func makeUIView(context: Context) -> CurrencyUITextField {
        return currencyField
    }
    
    func updateUIView(_ uiView: CurrencyUITextField, context: Context) { }
}

This is pretty simple, I only make sure the view is created once for time being.

Back to SwiftUI view, let’s see how it goes.

struct ContentView: View {
    @State private var isSubtitleHidden = false
    @State private var value = 0
    
    private var numberFormatter: NumberFormatter
    
    init(numberFormatter: NumberFormatter = NumberFormatter()) {
        self.numberFormatter = numberFormatter
        self.numberFormatter.numberStyle = .currency
        self.numberFormatter.maximumFractionDigits = 2
    }

    var body: some View {
        VStack(spacing: 20) {
            
            Text("Send money")
                .font(.title)
            
            CurrencyTextField(numberFormatter: numberFormatter, value: $value)
                .padding(20)
                .overlay(RoundedRectangle(cornerRadius: 16)
                            .stroke(Color.gray.opacity(0.3), lineWidth: 2))
                .frame(height: 100)
            
            Rectangle()
                .frame(width: 0, height: 40)
            
            Text("Send")
                .fontWeight(.bold)
                .padding(30)
                .frame(width: 180, height: 50)
                .background(Color.yellow)
                .cornerRadius(20)
                .onTapGesture {
                    if !isSubtitleHidden {
                        isSubtitleHidden.toggle()
                    }
                }
                
                
            if isSubtitleHidden {
                Text("Sending \(value)")
            }
            
            Spacer()
        }
        .padding(.top, 60)
        .padding(.horizontal, 20)
    }
}

Most currency transfers and payment handle amount at the fraction level. It means that $12.34 would be represented as 1234 so I kept my binding value the same way.

Here is how it looks

currency-swiftui

One thing I haven’t got chance to test is localization. Thing is environment variable locale doesn’t seem to apply to formatters during preview without a small effort. So, similar to what I did in the past for DateFormatter, I created a protocol and preview type to make it work.

protocol NumberFormatterProtocol: AnyObject {
    func string(from number: NSNumber) -> String?
    func string(for obj: Any?) -> String?
    var numberStyle: NumberFormatter.Style { get set }
    var maximumFractionDigits: Int { get set }
}

extension NumberFormatter: NumberFormatterProtocol { }

class PreviewNumberFormatter: NumberFormatterProtocol {
    
    let numberFormatter: NumberFormatter
    
    init(locale: Locale) {
        numberFormatter = NumberFormatter()
        numberFormatter.locale = locale
    }
    
    var numberStyle: NumberFormatter.Style {
        get {
            return numberFormatter.numberStyle
        }
        set {
            numberFormatter.numberStyle = newValue
        }
    }
    
    var maximumFractionDigits: Int {
        get {
            return numberFormatter.maximumFractionDigits
        }
        set {
            numberFormatter.maximumFractionDigits = newValue
        }
    }
    
    func string(from number: NSNumber) -> String? {
        return numberFormatter.string(from: number)
    }
    
    func string(for obj: Any?) -> String? {
        numberFormatter.string(for: obj)
    }
}

I can now update the view accordingly and preview any specific region without launching the app or editing the current scheme.

struct ContentView: View {
    @State private var isSubtitleHidden = false
    @State private var value = 0
    
    private var numberFormatter: NumberFormatterProtocol
    
    init(numberFormatter: NumberFormatterProtocol = NumberFormatter()) {
      self.numberFormatter = numberFormatter
      ...
    }
    ...
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(numberFormatter: 
                        PreviewNumberFormatter(locale: Locale(identifier: "fr_FR"))
        )
    }
}

Testing for French region and language, we can get into EUR:

currency-localized

Before concluding this post, here are few things to note.

If the custom view has the feeling I was looking for, it required to set the style and observers within the view itself. Those aren’t exposed to SwiftUI, so it would require to create few ViewModifier to make things right.

The currency manipulation has also been inspired by a similar question asked on StackOverflow. It works decently in this demo, but I would be careful using it into production, transforming String to Decimal to then Double can expose you to rounded numbers (or too accurate like 10.1999999) and give you an approximative output.


In conclusion, even though the default TextField in SwiftUI is a great place to start to handle currency inputs, I had to bridge back from UIKit to get a more complete experience for what I was looking for.

I feel TextField have still few gaps in SwiftUI like UITextFieldDelegate and few observers that UIKit has that are key for a more advance experience.

That being said, as always, each project is different and might require a special touch to make things right, bridging views from UIKit shouldn’t be the default solution to all problems.

This code is available on Github.

Happy coding

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬