A polished iOS currency input screen built entirely with SwiftUI. Features a custom numeric keyboard, adaptive typography, animated state transitions, and haptic feedback — with no third-party dependencies.
Recorded on iPhone 17.
Tested on iPhone SE (3rd generation) — the smallest screen that runs iOS 17 (4.7").
| Layer | Choice |
|---|---|
| Language | Swift |
| UI Framework | SwiftUI |
| Architecture | MVVM |
| State Management | @Observable (Swift 5.9+) |
| Concurrency | @MainActor |
| Formatting | NumberFormatter (Foundation) |
| Haptics | UIImpactFeedbackGenerator |
| Dependencies | None |
- Xcode 26+
- iOS 17.0+
- Swift 5.9+
MagicalCurrencyInput/
├── MagicalCurrencyInputApp.swift # App entry point (NavigationStack)
├── CurrencyInputView.swift # Main screen
├── Localizable.xcstrings # String catalog for all UI-facing text
├── ViewModel/
│ └── CurrencyInputViewModel.swift # Input logic, state, display formatting
├── Extension/
│ └── CurrencyConverter.swift # Currency enum + Float/String extensions
├── View/
│ ├── ValueViewer.swift # Adaptive-size amount display with caret
│ ├── Caret.swift # Blinking cursor with ViewModifier
│ ├── KeyboardView.swift # Custom 4×3 numeric grid
│ ├── KeyboardKeyView.swift # Individual key button
│ ├── KeyboardBackButton.swift # Backspace key
│ ├── BubbleContainer.swift # Suggested-value row
│ ├── BubbleView.swift # Individual suggestion pill
│ ├── TagView.swift # "AUTOMATED" label with gradient border
│ ├── MainButton.swift # "Review" CTA with gradient glow
│ └── BackButton.swift # Custom nav back button (ViewModifier)
├── DesignSystem/
│ ├── Fonts.swift # GT Flexa + Instrument Sans wrappers
│ └── Colors.xcassets/ # Semantic color tokens
└── Resources/
├── GTFlexa-CnMd.otf
└── InstrumentSansSemiCondensed-Medium.otf
- Custom numeric keyboard — no system keyboard involved
- Decimal separator is disabled once entered (prevents double dots)
- Input capped at 2 decimal places
- Backspace removes the last digit; clears to
"0"when empty - Quick-select suggestions ($500 · $2,000 · $10,000) populate the field instantly
ViewThatFitsprogressively drops the font size (100pt → 84pt → 52pt) instead of truncating- Single-line constraint keeps the amount always on one row
$0shown at 30% opacity while the field is empty- Animated blinking caret (repeating easeInOut, 0.8 s period)
- Radial gradient fades in at the top of the screen as soon as input is non-zero
.blurReplacetransition swaps between the suggestion bubbles and the "Review" buttonTagViewuses an angular gradient border cycling through brand and accent colorsMainButtonrenders a blurred linear gradient glow beneath the capsule label
UIImpactFeedbackGenerator(.soft)fires on every keyboard tap
- All UI-facing strings are registered in
Localizable.xcstrings(Xcode String Catalog format) String(localized:)is used at every call site so Xcode can extract and validate keys at build time- Strings in
CurrencyConverter.swift(locale codes, currency symbols) are intentionally excluded — they are locale identifiers, not user-visible copy
Four currencies are supported out of the box via the Currency enum, each backed by a NumberFormatter locale:
| Currency | Locale | Symbol | Decimal | Grouping |
|---|---|---|---|---|
| USD | en_US |
$ |
. |
, |
| EUR | de_DE |
€ |
, |
. |
| GBP | en_GB |
£ |
. |
, |
| BRL | pt_BR |
R$ |
, |
. |
Adding a new currency requires only a new case in the Currency enum with its locale code and symbol.
Tests are written with Swift Testing (@Suite / @Test) and live in MagicalCurrencyInputTests/.
| File | Suites | What's covered |
|---|---|---|
CurrencyInputViewModelTests.swift |
7 | USD input logic — initial state, digit append, decimal separator, backspace, suggested values, display formatting, computed flags |
CurrencyConverterTests.swift |
5 | EUR/GBP/BRL currency properties, comma-locale string extensions, GBP and BRL display formatting, EUR decimal-separator behaviour |
- Coverage: 97.7% on
CurrencyInputViewModel.swift - Coverage: 93.2% on
CurrencyConverterTests.swift
Run all tests from the terminal:
xcodebuild test -project MagicalCurrencyInput.xcodeproj -scheme MagicalCurrencyInput -destination 'platform=iOS Simulator,name=iPhone 17'
| Token | Typeface | Usage |
|---|---|---|
DesignSystemFonts.gtFlexa |
GT Flexa Condensed Medium | Amount display, tag label, buttons |
DesignSystemFonts.instrumentSans |
Instrument Sans Semi-Condensed Medium | Suggestion bubbles |
Semantic color tokens are defined in Colors.xcassets and referenced via Color(.background), Color(.text), Color(.bubble), etc.
CurrencyInputViewModel owns the raw inputValue string and exposes only derived, read-only properties (displayValue, hasInput, isDecimalSeparatorDisabled) to the view. Currency formatting logic lives in CurrencyConverter.swift as extensions on Float and String, keeping the view model free of NumberFormatter details.
See DECISIONS.md for the trade-off analysis behind the gradient-border-on-text approach.
Each keystroke mutates inputValue on the @Observable view model, invalidating every derived property and re-running CurrencyInputView.body. The following techniques keep that hot path cheap.
A fully configured NumberFormatter is built once per Currency case and reused for every Float.toCurrency(_:) call.
fileprivate static let formatters: [Currency: NumberFormatter] = {
var dict: [Currency: NumberFormatter] = [:]
for currency in Currency.allCases {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: currency.code)
formatter.currencySymbol = currency.symbol
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
dict[currency] = formatter
}
return dict
}()Currency.decimalSeparator and Currency.groupingSeparator resolve from a single static let dictionary populated once at first access — one Locale construction per case, then dictionary lookups for the rest of the app's lifetime.
isZero scans for any digit 1‒9 rather than parsing the string as a Float:
extension String {
var isZero: Bool {
!contains { $0 >= "1" && $0 <= "9" }
}
}This is sound because inputValue can only contain digits 0‒9 and the locale decimal separator.
Gradient values are immutable and declared as private static let on each view, so the gradient expression evaluates once for the type rather than once per struct initialization:
CurrencyInputView.radialGradient— background glowTagView.borderGradient— angular borderBubbleView.gradient— linear rimMainButton.glowGradient— linear glow
KeyboardView.digitRows follows the same pattern as a static let.
CurrencyInputView holds a single UIImpactFeedbackGenerator(style: .soft) in @State. It is .prepare()d on .onAppear, and each tap calls .impactOccurred() followed by .prepare() so the next tap fires without engine wake-up latency.
KeyboardView, KeyboardKeyView, and KeyboardBackButton conform to Equatable (comparing only their visible props — the trailing-closure action captures only stable class references, so it's safe to ignore in equality) and each is wrapped in .equatable() at its call site. SwiftUI skips re-bodying the keypad when its visible inputs are unchanged, so typing digits doesn't re-evaluate the 12-button grid.
The .blurReplace transition between BubbleContainer and MainButton is driven by one .animation(.linear(duration: animationDuration), value: viewModel.hasInput) on the wrapping VStack. Children declare .transition(.blurReplace) only; the animation modifier lives at a single level.




