Amount FieldNew
A currency amount entry that formats live in the user's locale as each digit lands (grouping, decimal separator, symbol before or after, the currency's own fraction digits), with a large auto-fitting figure whose digits roll in, a blinking caret right after the digits, robust paste of amounts like "$1,234.56" or "1 234,56 €", and a limit that refuses the keystroke with a damped shake, an error haptic and a message line, all backed by a `Decimal` binding.
- Type
- AmountField
- Files
- AmountField.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 1.0.0
Currency amount entry with live locale formatting, a Decimal value and a limit. The field keeps its own digit buffer and renders the formatted string itself, so the caret never jumps and locale separators never break parsing. Formatting follows \.locale; fraction digits come from the currency.
Notes
- The field keeps its own buffer of typed digits and renders the formatted string itself. A hidden
TextFieldwith.decimalPadcaptures keys, so the caret never jumps and grouping separators never reach a parser. The caret is drawn right after the digits, before a trailing symbol ("1.234,56| €"). - The value is a
Decimal. Fraction digits come from the currency throughDecimal.FormatStyle.Currency: JPY takes none, USD two, KWD three. Formatting follows\.locale, so.environment(\.locale, Locale(identifier: "de_DE"))gives "1.234,56 €", en_IN groups in lakhs ("₹1,23,456") and ar_SA shows Arabic-Indic digits. - While typing, what you typed is what you see: "12." keeps its separator and "12.50" keeps its zero. Leading zeros are stripped, "." on an empty field starts "0.", a second separator is ignored and digits past the currency's precision are dropped. Leaving the field settles "12.5" to "12.50" and "12." to "12".
- Both "." and "," (and the Arabic decimal separator) work as the decimal key, whatever the device region. Digits in any script are accepted and stored as ASCII.
- Paste (long-press, or Command-V) parses "1,234.56", "$99", "1 234,56 €", "US$ 12" and Arabic-Indic text. A lone separator followed by exactly three digits reads as thousands unless the currency has three decimals. Words, two numbers or a minus sign are rejected with an error haptic.
limitrefuses the keystroke or paste that would pass it: a damped shake, an.errorhaptic, a VoiceOver announcement and thelimitMessageline, which fades after a moment. An external value over the limit is shown inStyle.errorwith the message, not clamped.- External writes to the binding (a quick-amount chip, a reset) redraw the display at the currency's precision. Negative or over-precise values are normalized and written back.
.herois a large light figure that scales down to fit its width, with digits rolling in through.numericText()..compactis a 56pt block with a focus ring. Both follow Dynamic Type.- VoiceOver reads the label and the full formatted amount as the value ("$1,234.56"), and the limit as a hint.
.disabled(true)dims the field and blocks focus.isFocusedlets you raise or dismiss the keyboard. - Reduce Motion: no shake (the digits flash
Style.errorinstead), and digit changes crossfade.
Usage
AmountFieldExample()Parameters
| Parameter | Description |
|---|---|
label | Caption above the amount, also the accessibility label. Pass an empty string to hide the caption. |
value | Bound amount. Never negative; external values are shown at the currency's precision (a negative or over-precise value is normalized and written back). |
currencyCode | ISO 4217 code. Sets the symbol and the fraction digits (USD 2, JPY 0, KWD 3). Defaults to the current locale's currency. |
limit | Largest amount that can be typed or pasted. A keystroke past it is refused with a shake (a color flash under Reduce Motion), an error haptic and the limit message. nil means no limit. |
limitMessage | Line shown when the limit refuses input or the value is over it. nil shows "Up to" plus the formatted limit; an empty string shows no line. |
allowsDecimal | Allow the decimal key. When false, amounts are whole units and the number pad has no decimal key. |
size | .hero is a large centered figure for a payments screen; .compact is a 56pt form-row block. |
isFocused | Optional binding to read or drive keyboard focus, for example to raise the keyboard on appear. |
style | Colors and metrics. Defaults to the Swift Pieces house palette, adapting to light and dark. |
Source
import SwiftUI
/// Currency amount entry with live locale formatting, a `Decimal` value and a limit.
///
/// The field keeps its own digit buffer and renders the formatted string itself, so the caret never jumps and
/// locale separators never break parsing. Formatting follows `\.locale`; fraction digits come from the currency.
///
/// - Parameters:
/// - label: Caption above the amount, also the accessibility label. Pass an empty string to hide the caption.
/// - value: Bound amount. Never negative; external values are shown at the currency's precision (a negative or over-precise value is normalized and written back).
/// - currencyCode: ISO 4217 code. Sets the symbol and the fraction digits (USD 2, JPY 0, KWD 3). Defaults to the current locale's currency.
/// - limit: Largest amount that can be typed or pasted. A keystroke past it is refused with a shake (a color flash under Reduce Motion), an error haptic and the limit message. `nil` means no limit.
/// - limitMessage: Line shown when the limit refuses input or the value is over it. `nil` shows "Up to" plus the formatted limit; an empty string shows no line.
/// - allowsDecimal: Allow the decimal key. When `false`, amounts are whole units and the number pad has no decimal key.
/// - size: `.hero` is a large centered figure for a payments screen; `.compact` is a 56pt form-row block.
/// - isFocused: Optional binding to read or drive keyboard focus, for example to raise the keyboard on appear.
/// - style: Colors and metrics. Defaults to the Swift Pieces house palette, adapting to light and dark.
public struct AmountField: View {
/// How prominent the field is.
public enum Size: Sendable {
/// A large centered figure that shrinks to fit its width, for a dedicated amount screen.
case hero
/// A form-row block with the caption above, like other Swift Pieces inputs.
case compact
}
/// Colors and metrics. `.standard` is the house palette.
public struct Style: Sendable {
/// The compact field block. The hero figure sits directly on your ground.
public var field: Color
/// Typed digits and the compact focus ring.
public var amount: Color
/// Currency symbol, caption and the empty "0".
public var secondaryLabel: Color
/// The blinking caret.
public var caret: Color
/// Over-limit digits, the error ring and the limit message.
public var error: Color
/// Compact field minimum height.
public var height: CGFloat
/// Compact field corner radius.
public var cornerRadius: CGFloat
/// Pass only what you want to change; `nil` keeps the house palette value.
public init(field: Color? = nil, amount: Color? = nil, secondaryLabel: Color? = nil, caret: Color? = nil, error: Color? = nil, height: CGFloat = 56, cornerRadius: CGFloat = 18) {
self.field = field ?? adaptive(light: 0xE9E7E1, dark: 0x262626)
self.amount = amount ?? adaptive(light: 0x141414, dark: 0xF4F3EF)
self.secondaryLabel = secondaryLabel ?? adaptive(light: 0x5C5A56, dark: 0xA6A49F)
self.caret = caret ?? adaptive(light: 0xFF5B3A, dark: 0xFF5B3A)
self.error = error ?? adaptive(light: 0xFF5B3A, dark: 0xFF5B3A)
self.height = max(height, 44)
self.cornerRadius = cornerRadius
}
public static let standard = Style()
}
@Environment(\.locale) private var locale
@Environment(\.layoutDirection) private var layoutDirection
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
@ScaledMetric(relativeTo: .largeTitle) private var heroSize: CGFloat = 72
@FocusState private var focused: Bool
@Binding private var value: Decimal
@State private var buffer: AmountBuffer
@State private var fieldText: String
@State private var shakeCount = 0
@State private var errorCount = 0
@State private var flash = false
@State private var noticeShown = false
@State private var caretOn = true
@State private var natural: CGFloat = 0
@State private var available: CGFloat = 0
private let label: String
private let currencyCode: String
private let limit: Decimal?
private let limitMessage: String?
private let allowsDecimal: Bool
private let size: Size
private let isFocused: Binding<Bool>?
private let style: Style
public init(_ label: String = "Amount", value: Binding<Decimal>, currencyCode: String = Locale.current.currency?.identifier ?? "USD", limit: Decimal? = nil, limitMessage: String? = nil, allowsDecimal: Bool = true, size: Size = .hero, isFocused: Binding<Bool>? = nil, style: Style = .standard) {
self.label = label
self._value = value
self.currencyCode = currencyCode
self.limit = limit
self.limitMessage = limitMessage
self.allowsDecimal = allowsDecimal
self.size = size
self.isFocused = isFocused
self.style = style
let digits = allowsDecimal ? AmountEngine.fractionDigits(code: currencyCode, locale: .current) : 0
let initial = AmountBuffer(value.wrappedValue, fractionDigits: digits)
self._buffer = State(initialValue: initial)
self._fieldText = State(initialValue: initial.canonical)
}
private var fractionDigits: Int { allowsDecimal ? AmountEngine.fractionDigits(code: currencyCode, locale: locale) : 0 }
private var isOver: Bool { limit.map { buffer.decimal > $0 } ?? false }
private var motion: Animation { reduceMotion ? .smooth(duration: 0.2) : .snappy(duration: 0.3) }
private var limitText: String? {
guard let limit else { return nil }
if let limitMessage { return limitMessage.isEmpty ? nil : limitMessage }
let formatted = AmountEngine.string(AmountBuffer(limit, fractionDigits: fractionDigits), code: currencyCode, locale: locale)
return "Up to \(formatted)"
}
public var body: some View {
let parts = AmountEngine.parts(buffer, code: currencyCode, locale: locale)
let spoken = AmountEngine.string(buffer, code: currencyCode, locale: locale)
let message = (noticeShown || isOver) ? limitText : nil
VStack(alignment: size == .hero ? .center : .leading, spacing: size == .hero ? 8 : 10) {
if !label.isEmpty {
Text(label.uppercased())
.font(.caption.weight(.semibold))
.tracking(0.8)
.foregroundStyle(focused && size == .hero ? style.amount : style.secondaryLabel)
.accessibilityHidden(true)
}
entry(parts, spoken: spoken)
if let message {
Text(message)
.font(.footnote.weight(.semibold))
.foregroundStyle(style.error)
.multilineTextAlignment(size == .hero ? .center : .leading)
.transition(reduceMotion ? .opacity : .move(edge: .top).combined(with: .opacity))
.accessibilityHidden(true)
}
}
.frame(maxWidth: .infinity, alignment: size == .hero ? .center : .leading)
.contentShape(.rect)
.onTapGesture { if isEnabled { focused = true } }
.opacity(isEnabled ? 1 : 0.45)
.animation(motion, value: message)
.animation(.smooth(duration: 0.2), value: focused)
.sensoryFeedback(.error, trigger: errorCount)
.onChange(of: fieldText) { _, new in handleInput(new) }
.onChange(of: value) { _, new in sync(from: new) }
.onChange(of: currencyCode) { sync(from: value, force: true) }
.onChange(of: locale) { sync(from: value, force: true) }
.onChange(of: allowsDecimal) { sync(from: value, force: true) }
.onChange(of: focused) { _, now in
isFocused?.wrappedValue = now
// Leaving the field settles "12.5" to "12.50" and "12." to "12".
if !now { replaceBuffer(AmountBuffer(buffer.decimal, fractionDigits: fractionDigits)) }
}
.onChange(of: isFocused?.wrappedValue ?? false) { _, wanted in
if isFocused != nil, wanted != focused { focused = wanted }
}
.onAppear {
sync(from: value, force: true)
if isFocused?.wrappedValue == true { focused = true }
}
.task(id: shakeCount) {
guard shakeCount > 0 else { return }
try? await Task.sleep(for: .seconds(2.4))
noticeShown = false
}
.task(id: flash) {
guard flash else { return }
try? await Task.sleep(for: .milliseconds(320))
withAnimation(.smooth(duration: 0.3)) { flash = false }
}
}
// MARK: Entry
private func entry(_ parts: AmountEngine.Parts, spoken: String) -> some View {
let shape = RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous)
let fit = natural > 0 && available > 0 ? min(1, available / natural) : 1
let hero = size == .hero
return display(parts)
.fixedSize()
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { natural = $0 }
.scaleEffect(fit, anchor: hero ? .center : (layoutDirection == .rightToLeft ? .trailing : .leading))
.animation(motion, value: fit)
.frame(maxWidth: .infinity, alignment: hero ? .center : .leading)
.accessibilityHidden(true)
.overlay {
// The real input: invisible, covering the figure, so a tap focuses it, long-press offers Paste and hardware keys work.
TextField(text: $fieldText, prompt: Text(verbatim: "")) { Text(label) }
.focused($focused)
.keyboardType(fractionDigits > 0 ? .decimalPad : .numberPad)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(.clear)
.tint(.clear)
.accessibilityLabel(label.isEmpty ? "Amount" : label)
.accessibilityValue(spoken)
.accessibilityHint(limitText ?? "")
}
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { available = $0 }
.padding(.horizontal, hero ? 0 : 18)
.padding(.vertical, hero ? 4 : 8)
.frame(minHeight: hero ? nil : style.height)
.background { if !hero { shape.fill(style.field) } }
.overlay {
if !hero {
shape.strokeBorder(isOver ? style.error : style.amount, lineWidth: 2)
.opacity(focused || isOver ? 1 : 0)
}
}
.modifier(AmountShake(count: reduceMotion ? 0 : CGFloat(shakeCount)))
.animation(.linear(duration: 0.45), value: shakeCount)
}
/// Symbol and digits in logical order, with the caret placed right after the digits whatever the layout direction.
private func display(_ parts: AmountEngine.Parts) -> some View {
let font: Font = size == .hero ? .system(size: min(heroSize, 150), weight: .light) : .title2.weight(.semibold)
let digits = buffer.isEmpty ? style.secondaryLabel : (isOver || flash ? style.error : style.amount)
let rtl = layoutDirection == .rightToLeft
let caretHeight = size == .hero ? min(heroSize, 150) * 0.78 : 26
return HStack(spacing: 0) {
if !parts.leading.isEmpty {
Text(parts.leading).foregroundStyle(style.secondaryLabel)
}
if rtl { caret(height: caretHeight) }
Text(parts.number)
.foregroundStyle(digits)
.contentTransition(reduceMotion ? .opacity : .numericText(value: NSDecimalNumber(decimal: buffer.decimal).doubleValue))
if !rtl { caret(height: caretHeight) }
if !parts.trailing.isEmpty {
Text(parts.trailing).foregroundStyle(style.secondaryLabel)
}
}
.font(font)
.monospacedDigit()
.lineLimit(1)
.tracking(size == .hero ? -1 : 0)
}
private func caret(height: CGFloat) -> some View {
Capsule()
.fill(style.caret)
.frame(width: size == .hero ? 3 : 2, height: height)
.padding(.horizontal, size == .hero ? 3 : 2)
.opacity(focused && caretOn ? 1 : 0)
.frame(width: focused ? nil : 0)
.task(id: CaretTick(focused: focused, text: buffer.canonical)) {
caretOn = true
guard focused else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(530))
guard !Task.isCancelled else { return }
caretOn.toggle()
}
}
}
private struct CaretTick: Equatable {
let focused: Bool
let text: String
}
// MARK: Input
/// Diffs the hidden field against the buffer: one character is a key, several are a paste, removals are backspaces.
private func handleInput(_ new: String) {
let old = buffer.canonical
guard new != old else { return }
let o = Array(old), n = Array(new)
var prefix = 0
while prefix < o.count, prefix < n.count, o[prefix] == n[prefix] { prefix += 1 }
var suffix = 0
while suffix < o.count - prefix, suffix < n.count - prefix, o[o.count - 1 - suffix] == n[n.count - 1 - suffix] { suffix += 1 }
let inserted = String(n[prefix..<(n.count - suffix)])
let removed = o.count - prefix - suffix
if inserted.count > 1 {
paste(inserted)
} else {
var next = removed >= o.count ? AmountBuffer() : buffer
if removed < o.count {
for _ in 0..<removed {
if case .changed(let b) = AmountEngine.apply(.backspace, to: next, fractionDigits: fractionDigits, limit: nil) { next = b }
}
}
if let character = inserted.first {
if let key = AmountEngine.key(for: character, locale: locale) {
switch AmountEngine.apply(key, to: next, fractionDigits: fractionDigits, limit: limit) {
case .changed(let b): next = b
case .ignored: break
case .overLimit: refuse(announce: true); next = buffer
case .tooLong: refuse(announce: false); next = buffer
}
}
}
commit(next)
}
// Put the hidden field back on the canonical text so the next diff starts clean.
if fieldText != buffer.canonical { fieldText = buffer.canonical }
}
private func paste(_ text: String) {
guard let parsed = AmountEngine.parse(text, locale: locale, fractionDigits: fractionDigits) else {
errorCount += 1
return
}
if let limit, parsed > limit { refuse(announce: true); return }
commit(AmountBuffer(parsed, fractionDigits: fractionDigits))
}
private func commit(_ next: AmountBuffer) {
guard next != buffer else { return }
withAnimation(motion) { buffer = next }
if noticeShown && !isOver { noticeShown = false }
if value != next.decimal { value = next.decimal }
}
private func refuse(announce: Bool) {
errorCount += 1
withAnimation(motion) { noticeShown = announce }
if reduceMotion {
withAnimation(.smooth(duration: 0.12)) { flash = true }
} else {
shakeCount += 1
}
if announce, let limitText {
AccessibilityNotification.Announcement(limitText).post()
}
}
/// Rebuilds the display from an external value, e.g. a quick-amount chip. Own writes compare equal and are skipped.
private func sync(from new: Decimal, force: Bool = false) {
guard force || new != buffer.decimal else { return }
let next = AmountBuffer(new, fractionDigits: fractionDigits)
if !force || next.canonical != buffer.canonical {
withAnimation(motion) { buffer = next }
}
if fieldText != next.canonical { fieldText = next.canonical }
if next.decimal != new { value = next.decimal }
}
private func replaceBuffer(_ next: AmountBuffer) {
guard next != buffer else { return }
buffer = next
fieldText = next.canonical
}
}
// MARK: - Engine
/// The typed amount as ASCII digits, kept apart from formatting so the display can be rebuilt on every keystroke.
private struct AmountBuffer: Equatable, Sendable {
/// Integer digits, "0"..."9" only, no leading zeros except a lone "0". Empty means nothing typed.
var integer = ""
/// Fraction digits typed after the decimal key; `nil` until the decimal key is pressed.
var fraction: String?
var isEmpty: Bool { integer.isEmpty && fraction == nil }
/// The locale-free canonical form, e.g. "1234.5"; also what the hidden text field holds.
var canonical: String { integer + (fraction.map { "." + $0 } ?? "") }
var decimal: Decimal {
let whole = integer.isEmpty ? "0" : integer
let text = fraction.map { $0.isEmpty ? whole : whole + "." + $0 } ?? whole
return Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) ?? 0
}
/// Builds the resting form of a value: integers show no fraction, anything else shows the currency's full precision.
init(_ value: Decimal, fractionDigits: Int) {
var input = max(value, 0), rounded = Decimal()
NSDecimalRound(&rounded, &input, fractionDigits, .plain)
let parts = (rounded as NSDecimalNumber).stringValue.split(separator: ".", omittingEmptySubsequences: false)
let whole = String(parts.first ?? "0")
integer = whole == "0" && rounded == 0 ? "" : whole
if parts.count > 1, fractionDigits > 0 {
let digits = String(parts[1])
fraction = digits + String(repeating: "0", count: max(fractionDigits - digits.count, 0))
if integer.isEmpty { integer = "0" }
}
}
init() {}
}
/// Pure formatting and parsing. Everything locale-specific goes through Foundation's currency format style.
private enum AmountEngine {
static let maxIntegerDigits = 12
static let posix = Locale(identifier: "en_US_POSIX")
enum Key: Equatable { case digit(Character), separator, backspace }
enum Outcome: Equatable {
case changed(AmountBuffer)
/// The key did nothing (a second separator, a digit past the currency's precision, backspace on empty).
case ignored
/// Accepting the key would pass the limit.
case overLimit
/// Accepting the key would pass the digit cap.
case tooLong
}
/// The display, split so the caret sits right after the digits and the symbol can be colored apart.
struct Parts: Equatable {
var leading: String
var number: String
var trailing: String
}
static func currencyStyle(_ code: String, _ locale: Locale) -> Decimal.FormatStyle.Currency {
Decimal.FormatStyle.Currency(code: code, locale: locale)
}
/// The currency's standard fraction digits in this locale (USD 2, JPY 0, KWD 3), read from the formatter itself.
static func fractionDigits(code: String, locale: Locale) -> Int {
let text = Decimal(0).formatted(currencyStyle(code, locale).attributed)
return text.runs.reduce(0) { count, run in
run.numberPart == .fraction ? count + text[run.range].characters.count : count
}
}
/// Formats exactly what was typed: "12." keeps its separator and "12.50" keeps its zero.
static func parts(_ buffer: AmountBuffer, code: String, locale: Locale) -> Parts {
let style = currencyStyle(code, locale)
.precision(.fractionLength(buffer.fraction?.count ?? 0))
.decimalSeparator(strategy: buffer.fraction == nil ? .automatic : .always)
let text = buffer.decimal.formatted(style.attributed)
var leading = "", number = "", trailing = ""
for run in text.runs {
let chunk = String(text[run.range].characters)
let isNumber = run.numberPart != nil || run.numberSymbol == .decimalSeparator || run.numberSymbol == .groupingSeparator
if isNumber { number += trailing; trailing = ""; number += chunk }
else if number.isEmpty { leading += chunk }
else { trailing += chunk }
}
if number.isEmpty { return Parts(leading: "", number: String(text.characters), trailing: "") }
return Parts(leading: leading, number: number, trailing: trailing)
}
/// The whole formatted string, for VoiceOver and callers.
static func string(_ buffer: AmountBuffer, code: String, locale: Locale) -> String {
let p = parts(buffer, code: code, locale: locale)
return p.leading + p.number + p.trailing
}
/// Maps one typed character to a key. Digits in any script (Arabic-Indic, Devanagari, full-width) become ASCII.
static func key(for character: Character, locale: Locale) -> Key? {
if let digit = asciiDigit(character) { return .digit(digit) }
if character == "." || character == "," || character == "\u{066B}" || String(character) == locale.decimalSeparator { return .separator }
return nil
}
static func asciiDigit(_ character: Character) -> Character? {
guard character.unicodeScalars.count == 1, let scalar = character.unicodeScalars.first,
scalar.properties.numericType == .decimal, let value = character.wholeNumberValue, (0...9).contains(value) else { return nil }
return Character(String(value))
}
static func apply(_ key: Key, to buffer: AmountBuffer, fractionDigits: Int, limit: Decimal?) -> Outcome {
var next = buffer
switch key {
case .backspace:
if var fraction = next.fraction {
if fraction.isEmpty { next.fraction = nil } else { fraction.removeLast(); next.fraction = fraction }
} else if !next.integer.isEmpty {
next.integer.removeLast()
} else {
return .ignored
}
return .changed(next)
case .separator:
guard fractionDigits > 0, next.fraction == nil else { return .ignored }
if next.integer.isEmpty { next.integer = "0" }
next.fraction = ""
return .changed(next)
case .digit(let digit):
if let fraction = next.fraction {
guard fraction.count < fractionDigits else { return .ignored }
next.fraction = fraction + String(digit)
} else if next.integer == "0" || next.integer.isEmpty {
if digit == "0" && next.integer == "0" { return .ignored }
next.integer = String(digit)
} else {
guard next.integer.count < maxIntegerDigits else { return .tooLong }
next.integer.append(digit)
}
if let limit, next.decimal > limit { return .overLimit }
return .changed(next)
}
}
/// Parses pasted text such as "1,234.56", "$99", "1 234,56 €", "١٬٢٣٤٫٥٦ ر.س." or "US$ 12". Returns `nil` for anything
/// that is not a single non-negative amount (words, two numbers, a minus sign).
static func parse(_ raw: String, locale: Locale, fractionDigits: Int) -> Decimal? {
let chars: [Character] = raw.trimmingCharacters(in: .whitespacesAndNewlines).map { asciiDigit($0) ?? $0 }
guard let first = chars.firstIndex(where: \.isASCIIDigitChar), let last = chars.lastIndex(where: \.isASCIIDigitChar) else { return nil }
if chars.contains(where: { "-\u{2212}(".contains($0) }) { return nil }
var start = first
let decimalMarks: Set<Character> = [".", ",", "\u{066B}"]
// ".50" or "$.50": a separator right before the first digit, not after a letter as in "Rs.12".
if start > 0, decimalMarks.contains(chars[start - 1]), start == 1 || !chars[start - 2].isLetter { start -= 1 }
for affix in [chars[..<start], chars[(last + 1)...]] {
guard affix.count <= 10, affix.filter(\.isLetter).count <= 4 else { return nil }
}
let body = chars[start...last]
let grouping: Set<Character> = ["'", "\u{2019}", "\u{066C}"]
for c in body where !(c.isASCIIDigitChar || decimalMarks.contains(c) || grouping.contains(c) || c.isWhitespace) { return nil }
let marks = body.indices.filter { decimalMarks.contains(body[$0]) }
var decimalAt: Int?
if let arabic = marks.last(where: { body[$0] == "\u{066B}" }) {
guard marks.filter({ body[$0] == "\u{066B}" }).count == 1 else { return nil }
decimalAt = arabic
} else if let lastMark = marks.last {
let c = body[lastMark]
let sameKind = marks.filter { body[$0] == c }.count
let kinds = Set(marks.map { body[$0] })
if kinds.count > 1 {
guard sameKind == 1 else { return nil }
decimalAt = lastMark
} else if sameKind == 1 {
let after = body[(lastMark + 1)...].filter(\.isASCIIDigitChar).count
// Three digits after a lone mark read as thousands ("1,234", "1.234") unless that mark is this
// locale's decimal separator and the currency actually carries three decimals (KWD "1.234").
let isGrouping = after == 3 && (fractionDigits < 3 || String(c) != locale.decimalSeparator)
decimalAt = isGrouping ? nil : lastMark
}
}
let wholeDigits = String((decimalAt.map { body[..<$0] } ?? body[...]).filter(\.isASCIIDigitChar))
let fractionDigitsText = decimalAt.map { String(body[($0 + 1)...].filter(\.isASCIIDigitChar)) } ?? ""
let trimmedWhole = wholeDigits.drop(while: { $0 == "0" })
guard trimmedWhole.count <= maxIntegerDigits else { return nil }
let text = (wholeDigits.isEmpty ? "0" : wholeDigits) + (fractionDigitsText.isEmpty ? "" : "." + fractionDigitsText)
guard var value = Decimal(string: text, locale: posix) else { return nil }
var rounded = Decimal()
NSDecimalRound(&rounded, &value, fractionDigits, .plain)
return rounded
}
}
private extension Character {
var isASCIIDigitChar: Bool { isASCII && isWholeNumber }
}
/// Damped sideways shake: four cycles that decay to rest over one unit of `count`.
private struct AmountShake: GeometryEffect {
var count: CGFloat
nonisolated var animatableData: CGFloat {
get { count }
set { count = newValue }
}
nonisolated func effectValue(size: CGSize) -> ProjectionTransform {
let t = count - count.rounded(.down)
let x = sin(t * .pi * 4) * 8 * (1 - t)
return ProjectionTransform(CGAffineTransform(translationX: x, y: 0))
}
}
/// A house-palette color that follows the interface style.
private func adaptive(light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
// MARK: - Example
/// A "Send money" hero entry in USD with a $2,500 limit and quick-amount chips, and a compact EUR field formatted for de_DE.
private struct AmountFieldExample: View {
@State private var amount: Decimal = 0
@State private var rent: Decimal = 1234.5
@State private var focused = true
@State private var chipTaps = 0
private let quick: [Decimal] = [20, 50, 100]
private let blocks: [UInt32] = [0xFFD976, 0x9CC2FF, 0xA9DCB7]
var body: some View {
VStack(spacing: 28) {
AmountField("Send", value: $amount, currencyCode: "USD", limit: 2500, isFocused: $focused)
.environment(\.locale, Locale(identifier: "en_US"))
HStack(spacing: 8) {
ForEach(Array(quick.enumerated()), id: \.offset) { index, preset in
Button {
amount = preset
chipTaps += 1
} label: {
Text(preset, format: .currency(code: "USD").precision(.fractionLength(0)).locale(Locale(identifier: "en_US")))
.font(.subheadline.weight(.semibold))
.foregroundStyle(adaptive(light: 0x141414, dark: 0x141414))
.padding(.horizontal, 18)
.frame(minHeight: 44)
.background(adaptive(light: blocks[index], dark: blocks[index]), in: .capsule)
}
.buttonStyle(.plain)
}
}
.sensoryFeedback(.selection, trigger: chipTaps)
AmountField("Miete", value: $rent, currencyCode: "EUR", size: .compact)
.environment(\.locale, Locale(identifier: "de_DE"))
}
.padding(.horizontal, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(adaptive(light: 0xF3F2EE, dark: 0x121212))
}
}
#Preview("Light") {
AmountFieldExample()
}
#Preview("Dark") {
AmountFieldExample()
.preferredColorScheme(.dark)
}Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
Building a whole app? See Swift Pieces Pro →
All components
Every free piece. Filter by category, open a piece for its parameters, source and install commands.
Date Range Picker
A start-to-end date range picker on a paged month grid built from the environment calendar and locale, where the first tap drops a solid butter start block, the second closes the range and a soft band sweeps day by day between the two blocks, wrapping across week rows with rounded caps, while sold-out days are struck through, a maximum length dims unreachable days, months page by swipe or chevrons with a rolling title, and a summary reads out the dates and nights.