Lists, navigation and input · core

Forms, input and focus

Text fields that behave, focus that moves on purpose, and validation that helps instead of nagging.

11 min read13 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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.

The focus chain and content types are recorded on the tree.
Run to see the preview.

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 emailError only if touched.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 attemptedSubmit and focuses the first invalid field — disabled-until-valid buttons leave users hunting for what is wrong.
The full timing policy as a testable model — quiet, then judged, then live.

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.

Check yourself

Why should the submit button stay enabled even while the form is invalid?

Implement the visibility rule

Write the code

PasswordForm validates eagerly but must present politely. Implement visiblePasswordError and visibleConfirmError with the touched/attempted policy.

Solution unlocks after 3 attempts

Advance the focus chain

Write the code

Implement nextField(after:) — the logic behind every .onSubmit { focused = … } chain — returning nil after the last field (which dismisses the keyboard).

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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?).

Checkpoint

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 Form vs custom layout deliberately

Next up: custom drawing — shapes, Canvas, and the hero-animation effect.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.