```html SwiftUI: How to Currency Input (iOS 17+, 2026)

How to implement currency input in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: NumberFormatter Updated: May 12, 2026
TL;DR

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

  1. Cents-first conversion. When the user types "1234", we divide by 100 to get 12.34. This avoids awkward decimal-point handling — the user never types a period, just digits. The line let decimal = Decimal(intValue) / 100 does the conversion using exact Decimal arithmetic, which avoids floating-point rounding errors on monetary values.
  2. Digit stripping with filter. newValue.filter { $0.isNumber } removes currency symbols, commas, and decimal points that NumberFormatter inserts back into displayText. Without this, the formatter's output would feed back into itself and crash on the next onChange.
  3. Locale-aware NumberFormatter. Setting f.locale = .current means 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.
  4. 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 the total computed property), and persistence straightforward.
  5. Accessibility value. The .accessibilityValue modifier 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

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?
The 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)?
Set 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?
In UIKit you'd subclass 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.

```