Skip to content

Urielbp/MagicalCurrencyInput

Repository files navigation

MagicalCurrencyInput

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.

Screenshots

Recorded on iPhone 17.

Empty state showing $0 with suggestion bubbles      Filled state showing $100 with Review button

Transition from $0 to $120 back to $0

Tested on iPhone SE (3rd generation) — the smallest screen that runs iOS 17 (4.7").

Decimal state showing $100.45 with the decimal separator key disabled after use      Maximum value state showing $100,000,000 with no decimal separator available

Stack

Layer Choice
Language Swift
UI Framework SwiftUI
Architecture MVVM
State Management @Observable (Swift 5.9+)
Concurrency @MainActor
Formatting NumberFormatter (Foundation)
Haptics UIImpactFeedbackGenerator
Dependencies None

Requirements

  • Xcode 26+
  • iOS 17.0+
  • Swift 5.9+

Project Structure

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

Features

Input Behaviour

  • 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

Display

  • ViewThatFits progressively drops the font size (100pt → 84pt → 52pt) instead of truncating
  • Single-line constraint keeps the amount always on one row
  • $0 shown at 30% opacity while the field is empty
  • Animated blinking caret (repeating easeInOut, 0.8 s period)

Visuals & Animation

  • Radial gradient fades in at the top of the screen as soon as input is non-zero
  • .blurReplace transition swaps between the suggestion bubbles and the "Review" button
  • TagView uses an angular gradient border cycling through brand and accent colors
  • MainButton renders a blurred linear gradient glow beneath the capsule label

Haptics

  • UIImpactFeedbackGenerator(.soft) fires on every keyboard tap

Localization

  • 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

Currency

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.

Testing

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'

Design System

Fonts

Token Typeface Usage
DesignSystemFonts.gtFlexa GT Flexa Condensed Medium Amount display, tag label, buttons
DesignSystemFonts.instrumentSans Instrument Sans Semi-Condensed Medium Suggestion bubbles

Colors

Semantic color tokens are defined in Colors.xcassets and referenced via Color(.background), Color(.text), Color(.bubble), etc.

Architecture Notes

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.

Performance

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.

Cached NumberFormatter per currency

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
}()

Cached locale separators

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.

String.isZero without Float parsing

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.

Static gradients

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 glow
  • TagView.borderGradient — angular border
  • BubbleView.gradient — linear rim
  • MainButton.glowGradient — linear glow

KeyboardView.digitRows follows the same pattern as a static let.

Prepared haptic generator

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.

Equatable keypad views

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.

Single animation modifier on the bubble/CTA swap

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.

About

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

Resources

Stars

Watchers

Forks

Contributors

Languages