How to implement currency input in SwiftUI
Hold a raw Decimal in @State, pair it with a String display value, and use NumberFormatter to convert between them on every keystroke. Show the TextField with .keyboardType(.decimalPad) and reformat on onChange.
struct CurrencyFieldDemo: View {
@State private var amount: Decimal = 0
@State private var displayText = ""
private let formatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currency
f.locale = .current
return f
}()
var body: some View {
TextField("$0.00", text: $displayText)
.keyboardType(.decimalPad)
.onChange(of: displayText) { _, newValue in
let digits = newValue.filter { $0.isNumber }
let cents = Decimal(string: digits) ?? 0
amount = cents / 100
displayText = formatter.string(from: amount as NSDecimalNumber) ?? ""
}
}
}
Full implementation
The cleanest approach wraps the logic in a reusable CurrencyTextField view. It stores the raw Decimal amount externally via a binding so parent forms can read it directly, while internally managing the formatted display string. The formatter is configured once as a stored property to avoid repeated allocation on every render.
import SwiftUI
// MARK: - Reusable currency text field
struct CurrencyTextField: View {
let label: String
@Binding var amount: Decimal
@State private var displayText: String = ""
@State private var isEditing = false
private let formatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currency
f.locale = .current
f.minimumFractionDigits = 2
f.maximumFractionDigits = 2
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
TextField(
formatter.string(from: 0) ?? "$0.00",
text: $displayText
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.accessibilityLabel(label)
.accessibilityValue(
formatter.string(from: amount as NSDecimalNumber) ?? "0"
)
.onAppear {
if amount != 0 {
displayText = formatter.string(
from: amount as NSDecimalNumber
) ?? ""
}
}
.onChange(of: displayText) { _, newValue in
// Strip everything except digits
let digits = newValue.filter { $0.isNumber }
guard !digits.isEmpty else {
amount = 0
displayText = ""
return
}
// Treat last two digits as cents
let intValue = Int(digits) ?? 0
let decimal = Decimal(intValue) / 100
amount = decimal
let formatted = formatter.string(
from: decimal as NSDecimalNumber
) ?? ""
// Only update display when not actively typing
// to avoid fighting the cursor
if !isEditing {
displayText = formatted
}
}
.onSubmit {
// Re-format cleanly on submit / Return
displayText = formatter.string(
from: amount as NSDecimalNumber
) ?? ""
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(isEditing ? Color.accentColor : Color.secondary.opacity(0.3))
)
}
}
}
// MARK: - Usage example
struct CheckoutForm: View {
@State private var price: Decimal = 0
@State private var tax: Decimal = 0
var total: Decimal { price + tax }
var body: some View {
Form {
Section("Pricing") {
CurrencyTextField(label: "Item price", amount: $price)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
CurrencyTextField(label: "Tax amount", amount: $tax)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
Section("Summary") {
LabeledContent("Total") {
Text(total, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
.fontWeight(.semibold)
}
}
Button("Confirm") {
print("Charging \(total)")
}
.disabled(price <= 0)
}
.navigationTitle("Checkout")
}
}
#Preview {
NavigationStack {
CheckoutForm()
}
}
How it works
-
Cents-first conversion. When the user types
"1234", we divide by 100 to get12.34. This avoids awkward decimal-point handling — the user never types a period, just digits. The linelet decimal = Decimal(intValue) / 100does the conversion using exactDecimalarithmetic, which avoids floating-point rounding errors on monetary values. -
Digit stripping with
filter.newValue.filter { $0.isNumber }removes currency symbols, commas, and decimal points thatNumberFormatterinserts back intodisplayText. Without this, the formatter's output would feed back into itself and crash on the nextonChange. -
Locale-aware
NumberFormatter. Settingf.locale = .currentmeans the formatter automatically uses the correct currency symbol, grouping separator (comma vs period), and decimal separator for each user's region — no manual country checks needed. -
Binding to
Decimal. The parent receives a@Binding<Decimal>that is always the clean numeric value, never the display string. This makes form validation, arithmetic (like thetotalcomputed property), and persistence straightforward. -
Accessibility value. The
.accessibilityValuemodifier provides VoiceOver with the formatted currency string rather than the raw digit string, so a screen reader announces "twelve dollars and thirty-four cents" rather than "one two three four".
Variants
Specific currency code (e.g. EUR)
// Force a specific currency regardless of user locale
private let euroFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currency
f.currencyCode = "EUR" // ISO 4217
f.currencySymbol = "€"
f.locale = Locale(identifier: "de_DE") // European decimal conventions
f.minimumFractionDigits = 2
f.maximumFractionDigits = 2
return f
}()
// Swap the formatter into CurrencyTextField by adding a
// formatter parameter:
// CurrencyTextField(label: "Price (EUR)", amount: $price, formatter: euroFormatter)
Using Swift's built-in .currency format style
For display-only labels (not editable fields), prefer the native FormatStyle introduced in iOS 15 — it requires no NumberFormatter at all and works directly with Decimal:
// Read-only display — no NumberFormatter needed
Text(amount, format: .currency(code: "USD"))
.font(.title2.monospacedDigit())
// In a TextField (iOS 17 format parameter)
// NOTE: this variant loses the "cents-first" UX
TextField(
"Amount",
value: $amount,
format: .currency(code: Locale.current.currency?.identifier ?? "USD")
)
.keyboardType(.decimalPad)
The format-style TextField is simpler but requires the user to type a decimal point manually. Use the cents-first NumberFormatter approach for payment flows where UX polish matters.
Common pitfalls
-
⚠️ Feedback loop in
onChange. If you write the formatted string back todisplayTexton every keystroke, the cursor jumps to the end. Defer the reformat to.onSubmitor focus-loss (@FocusState) to keep the cursor stable while typing. -
⚠️ Never use
Doublefor money.Double(0.1) + Double(0.2)is not0.3in IEEE 754. Always store monetary values asDecimal(orIntcents) and only convert toNSDecimalNumberwhen passing toNumberFormatter. -
⚠️ Locale mismatch between formatter and
Decimal(string:).Decimal(string: "1,234")returnsnilin a US locale (comma is the grouping separator, not the decimal separator). Always strip non-digit characters before callingDecimal(string:). -
⚠️ Missing zero state. When the user clears the field,
digitsis empty. Guard this case and resetamount = 0anddisplayText = ""explicitly — otherwise the previous value lingers invisibly in state.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement currency input in SwiftUI for iOS 17+. Use NumberFormatter with .numberStyle = .currency and .locale = .current. Store the value as Decimal (not Double) to avoid floating-point errors. Support cents-first entry (user types digits; divide by 100 for the amount). Make it accessible (VoiceOver labels with formatted currency string as accessibilityValue). Add a #Preview with realistic sample data showing a checkout form.
In the Soarias Build phase, drop this prompt into the active feature branch — Claude Code will generate the CurrencyTextField component, wire it into your existing form, and produce a preview you can validate on device before moving to the Polish phase.
Related
FAQ
Does this work on iOS 16?
onChange(of:) two-parameter closure ({ _, newValue in }) requires iOS 17. On iOS 16 you must use the single-parameter form onChange(of:perform:). The #Preview macro also requires Xcode 15+ / iOS 17 SDK. The NumberFormatter logic itself works back to iOS 15, so if you need iOS 16 support just swap the onChange signature and replace #Preview with PreviewProvider.
How do I handle currencies with no decimal places (JPY, KRW)?
f.maximumFractionDigits = 0 and f.minimumFractionDigits = 0 on the formatter, and remove the / 100 division — treat typed digits as whole units. You can detect this automatically: Locale.current.currency.map { NumberFormatter.currencyFractionDigits(for: $0) } via a custom helper, or simply check f.maximumFractionDigits after setting the locale to decide whether to divide by 100 or 1.
What's the UIKit equivalent?
UITextField and implement textField(_:shouldChangeCharactersIn:replacementString:) from UITextFieldDelegate, performing the same digit-filter and formatter logic there. The SwiftUI approach replaces that delegate entirely with onChange, which is considerably cleaner and fully reactive with the rest of your view state.
Last reviewed: 2026-05-12 by the Soarias team.