How to Build a Phone Number Input in SwiftUI
Store raw digits in a @State string, format them into (XXX) XXX-XXXX on every keystroke via an onChange modifier, and bind a display string to TextField with .keyboardType(.phonePad).
struct PhoneField: View {
@State private var digits = ""
@State private var display = ""
var body: some View {
TextField("(555) 000-0000", text: $display)
.keyboardType(.phonePad)
.onChange(of: display) { _, new in
let raw = new.filter(\.isNumber)
digits = String(raw.prefix(10))
display = formatted(digits)
}
}
func formatted(_ d: String) -> String {
var s = d
if s.count > 6 { s.insert("-", at: s.index(s.startIndex, offsetBy: 6)) }
if s.count > 3 { s.insert(") ", at: s.index(s.startIndex, offsetBy: 3)) }
if !s.isEmpty { s.insert("(", at: s.startIndex) }
return s
}
}
Full implementation
The cleanest approach keeps two separate pieces of state: raw digits (used for validation and submission) and a display string (shown to the user with punctuation). The onChange(of:) modifier — using the two-closure form required in iOS 17 — strips non-digits, caps the length at 10, and rebuilds the formatted string every time the user types. A small isValid computed property enables or disables the submit button based on digit count, and an optional country-code prefix keeps the field composable inside larger forms.
import SwiftUI
// MARK: – Formatter helper
private func formatPhone(_ digits: String) -> String {
let d = String(digits.prefix(10))
var result = ""
for (i, ch) in d.enumerated() {
switch i {
case 0: result += "(\(ch)"
case 3: result += ") \(ch)"
case 6: result += "-\(ch)"
default: result += "\(ch)"
}
}
return result
}
// MARK: – View
struct PhoneNumberInputView: View {
/// Callback receives the raw 10-digit string (e.g. "5551234567")
var onCommit: (String) -> Void = { _ in }
@State private var rawDigits = ""
@State private var display = ""
@State private var isInvalid = false
@FocusState private var focused: Bool
private var isValid: Bool { rawDigits.count == 10 }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Phone number")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
// Country code prefix (static; swap for CountryPicker for full i18n)
Text("+1")
.foregroundStyle(.secondary)
.padding(.leading, 12)
TextField("(555) 000-0000", text: $display)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.focused($focused)
.accessibilityLabel("Phone number field")
.accessibilityHint("Enter your 10-digit US phone number")
.onChange(of: display) { _, newValue in
let raw = newValue.filter(\.isNumber)
rawDigits = String(raw.prefix(10))
display = formatPhone(rawDigits)
isInvalid = false
}
.onSubmit {
guard isValid else { isInvalid = true; return }
onCommit(rawDigits)
}
if isValid {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.padding(.trailing, 12)
.transition(.scale.combined(with: .opacity))
.accessibilityLabel("Valid phone number")
}
}
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isInvalid ? Color.red :
focused ? Color.accentColor :
Color(uiColor: .separator),
lineWidth: focused ? 2 : 1
)
)
.animation(.easeInOut(duration: 0.2), value: focused)
.animation(.easeInOut(duration: 0.2), value: isValid)
if isInvalid {
Text("Please enter a valid 10-digit number.")
.font(.caption)
.foregroundStyle(.red)
.transition(.opacity)
.accessibilityLiveRegion(.polite)
}
Button {
guard isValid else { isInvalid = true; return }
focused = false
onCommit(rawDigits)
} label: {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!isValid)
.padding(.top, 8)
}
.padding()
}
}
// MARK: – Preview
#Preview("Phone Input") {
PhoneNumberInputView { digits in
print("Submitted:", digits)
}
.padding()
}
How it works
-
Two-state separation.
rawDigitsis always a clean numeric string of up to 10 characters;displayis what theTextFieldactually renders. This means your validation logic never has to parse punctuation out of user input. -
onChange two-closure form. iOS 17 deprecated the single-value
onChange(of:perform:)overload. The snippet usesonChange(of: display) { _, newValue in … }, which receives both old and new values and compiles without warnings on Xcode 16. -
formatPhone(_:)helper. A simpleforloop over character indices inserts(,), and-at positions 0, 3, and 6 respectively. Because we always reformat fromrawDigits, deleting characters works correctly — users can't accidentally break the mask. -
Focus ring & error state.
@FocusStatedrives the stroke colour of thestrokeBordermodifier — accent-coloured when focused, red whenisInvalidis true, default separator otherwise. Both animate with.animation(.easeInOut, value:). -
Accessibility.
.textContentType(.telephoneNumber)tells iOS to suggest contacts from the QuickType bar. The errorTextuses.accessibilityLiveRegion(.polite)so VoiceOver announces validation failures without interrupting the user.
Variants
International E.164 format with PhoneNumberFormatter
When you need to support country codes beyond +1, lean on PhoneNumberFormatter (available in iOS 17's Contacts framework) to validate and canonicalise:
import Contacts
func e164(from display: String, regionCode: String = "US") -> String? {
// CNPhoneNumber normalises the string; check digit count for region
let stripped = display.filter(\.isNumber)
// Minimal guard — use libPhoneNumber-iOS for production
guard stripped.count >= 7 else { return nil }
// Prepend region dial code stored separately (e.g. "+1")
return "+\(stripped)"
}
// Usage inside onChange:
let canonical = e164(from: display, regionCode: selectedRegion)
isValid = canonical != nil
Read-only formatted display
To show a stored phone number as formatted text (not editable), call formatPhone(digits) directly inside a Text view. No TextField or state management needed:
Text(formatPhone("5551234567")) // → (555) 123-4567
Common pitfalls
-
⚠️ iOS 16 onChange signature. The two-argument
onChange(of:) { old, new in }closure form was introduced in iOS 17. If you target iOS 16 you must use the deprecatedonChange(of: display, perform: { new in … })form, or wrap with a customBindingsetter instead. -
⚠️ Infinite onChange loop. Setting
displayinsideonChange(of: display)can cause an infinite update loop if the formatter is not idempotent. Always strip then reformat fromrawDigitsso that a second call produces the same result and SwiftUI short-circuits. -
⚠️ Paste containing non-digits. Users may paste a formatted number like
+1 (555) 123-4567. Thefilter(\.isNumber)call handles this automatically, but caprawDigitswithprefix(10)to avoid formatting more than 10 digits when the user pastes a number with a country code included.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a phone number input in SwiftUI for iOS 17+. Use TextField with a custom formatPhone() helper and @FocusState. Apply .keyboardType(.phonePad) and .textContentType(.telephoneNumber). Expose a rawDigits binding so parent forms can validate E.164. Make it accessible (VoiceOver labels, .accessibilityLiveRegion for errors). Add a #Preview with realistic sample data showing both empty and pre-filled states.
Drop this prompt into Soarias during the Build phase after your screen mockups are approved — Claude Code will wire the component into your existing form file without breaking surrounding layout.
Related
FAQ
Does this work on iOS 16?
Partially. The two-argument onChange(of:) { _, new in } syntax requires iOS 17. On iOS 16 use onChange(of: display, perform: { new in … }) instead. The #Preview macro and @FocusState both work back to iOS 15.
How do I support international phone numbers beyond the US?
Pair this field with a country picker that stores a dial code (e.g. "+44") and adjust both the digit cap and the mask pattern per region. For production apps consider the libPhoneNumber-iOS Swift Package, which provides region-aware validation and formatting for every country.
What's the UIKit equivalent?
In UIKit you'd subclass UITextField and implement textField(_:shouldChangeCharactersIn:replacementString:) from UITextFieldDelegate to intercept and reformat each keystroke. The SwiftUI onChange approach is equivalent but requires no delegate pattern or subclassing.
Last reviewed: 2026-05-12 by the Soarias team.