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.
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
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:
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