Forms, input and focus
Text fields that behave, focus that moves on purpose, and validation that helps instead of nagging.
By the end you will be able to
- Configure text fields with the right keyboard, content type and submit behaviour
- Drive focus programmatically with @FocusState
- Implement validation that appears at the right moment, not on every keystroke
A sign-up form shows "Invalid email" in red the moment the user types the first character — a is, after all, not a valid email. What is the actual defect?
Configuring the field, not fighting the keyboard
A text field's configuration modifiers are promises to the system about what is being typed — and each unlocks real behaviour:
TextField("Email", text: $email)
.keyboardType(.emailAddress) // @ and . on the main layer
.textContentType(.emailAddress) // enables autofill from saved addresses
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Password", text: $password)
.textContentType(.newPassword) // triggers the password-manager suggestion
TextField("One-time code", text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode) // SMS codes auto-fill above the keyboard
.textContentType is the one teams forget, and it is the highest-leverage line on this page: it is what makes iOS offer saved emails, generate strong passwords, and auto-fill SMS codes. A login form without content types forces every user to type credentials a password manager already knows — a conversion cost measured in real drop-off.
For multi-line input, TextField grows along an axis, and TextEditor handles free-form text:
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(3...6) // grows from 3 to 6 lines, then scrolls
Form — the container that styles for you
Form {
Section("Account") {
TextField("Email", text: $email)
SecureField("Password", text: $password)
}
Section {
Toggle("Newsletter", isOn: $subscribed)
Picker("Plan", selection: $plan) {
ForEach(Plan.allCases) { Text($0.title) }
}
} footer: {
Text("You can change the plan at any time.")
}
}
Form applies the platform's settings-screen styling to everything inside — grouped rows, correct insets, pickers that navigate. The rule of thumb: settings and data entry go in a Form; custom-designed input (search bars, composers) stays in your own layout. "Fighting" is a specific smell: a composer inside a Form accumulates .listRowInsets(EdgeInsets()), .listRowBackground(Color.clear), .listRowSeparator(.hidden) — three overrides erasing chrome you never wanted — when a three-line VStack was the whole design. If most of your modifiers are undoing the container, you chose the wrong container.
@FocusState — focus as data
Focus — which field owns the keyboard — is state, and SwiftUI models it with a property wrapper whose value you can read and write:
struct SignupForm: View {
enum Field {
case email, password, confirm
}
@FocusState private var focused: Field?
@State private var email = ""
@State private var password = ""
var body: some View {
Form {
TextField("Email", text: $email)
.focused($focused, equals: .email)
.submitLabel(.next) // the return key says "next"
.onSubmit { focused = .password } // and moves focus
SecureField("Password", text: $password)
.focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit { submit() }
}
.onAppear { focused = .email } // first field ready immediately
}
}
The pattern to internalise: an enum of fields, one @FocusState optional, .focused($focused, equals:) on each field. Then assignment moves focus (focused = .password), nil dismisses the keyboard (focused = nil), and reading tells you where the user is. The return-key chain — each field's .submitLabel(.next) + onSubmit advancing the enum — is what makes a form feel engineered rather than assembled.
Validation that helps
Now the pretest's lesson as state design. Three pieces of state per form, and a rule for combining them:
@State private var email = ""
@State private var touched: Set<Field> = [] // fields the user has LEFT
@State private var attemptedSubmit = false
- Validity is derived, always current:
var emailError: String? { … }— the derive-don't-store rule. - Visibility is the product decision: show
emailErroronly iftouched.contains(.email) || attemptedSubmit. - Leaving a field marks it touched — observe focus changes with
.onChange(of: focused). - The submit button stays enabled, and submitting with errors sets
attemptedSubmitand focuses the first invalid field — disabled-until-valid buttons leave users hunting for what is wrong.
Note the third line: once shown, the error vanished the keystroke the field became valid — no "dismiss" logic, because visibility is derived from current validity. The whole policy is two booleans and two computed properties, and it ports to any form you will ever build.
Why should the submit button stay enabled even while the form is invalid?
Implement the visibility rule
PasswordForm validates eagerly but must present politely. Implement visiblePasswordError and visibleConfirmError with the touched/attempted policy.
Advance the focus chain
Implement nextField(after:) — the logic behind every .onSubmit { focused = … } chain — returning nil after the last field (which dismisses the keyboard).
Design the input experience for an address form (name, street, city, postcode, country): the content types per field, the focus chain including which field uses which submit label, where the country picker interrupts the keyboard flow, and the validation timing for the postcode (format depends on country — when do you judge it?).
You can now:
- Configure fields so the system's autofill and keyboards work for the user
- Drive focus as data: chains, programmatic moves, keyboard dismissal
- Implement touched/attempted validation with derived visibility
- Choose
Formvs custom layout deliberately
Next up: custom drawing — shapes, Canvas, and the hero-animation effect.