Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 025 / 100

Forms in SwiftUI: Swift & iOS best practices explained

Settings and input screens.

Expona Admin8 min read

Introduction

Building effective forms is one of the most common tasks in iOS development, yet many teams struggle with validation, state management, and user experience. Forms in SwiftUI: Swift & iOS best practices explained covers the essential patterns for settings and input screens that feel polished and respond intuitively to user input.

In this guide, we'll walk through structuring forms with sections, managing multiple input types, validating data before submission, and handling focus elegantly. Whether you're building a sign-up flow, a settings panel, or a data entry screen, these patterns will help your team ship forms that users actually enjoy filling out.

Form with Sections

The foundation of any good form is clear visual hierarchy. SwiftUI's Form container paired with Section views lets us group related fields and add descriptive headers and footers without fighting layout code.

swift
struct SettingsFormView: View {
    @State private var username: String = ""
    @State private var email: String = ""
    @State private var notificationsEnabled: Bool = true
    @State private var theme: String = "Light"
    
    var body: some View {
        Form {
            Section(header: Text("Account")) {
                TextField("Username", text: $username)
                TextField("Email", text: $email)
            }
            
            Section(header: Text("Preferences"), footer: Text("Changes apply immediately")) {
                Toggle("Enable Notifications", isOn: $notificationsEnabled)
                Picker("Theme", selection: $theme) {
                    Text("Light").tag("Light")
                    Text("Dark").tag("Dark")
                    Text("System").tag("System")
                }
            }
        }
        .navigationTitle("Settings")
    }
}

Breaking your form into logical sections does more than look good—it reduces cognitive load. Users scanning a preferences screen know that account settings sit together, separate from notification toggles. On longer forms, sections help scroll performance and make the interface feel organized rather than overwhelming.

In production apps handling user onboarding, we've found that grouping similar fields by purpose (personal info, payment details, preferences) increases completion rates. The visual separation signals to users that they're progressing through meaningful steps, not just filling random boxes.

TextField, Toggle, Picker and DatePicker

SwiftUI provides a solid toolkit for common input types. Understanding when to use each and how to configure them properly is critical for responsive forms.

TextField for text input

swift
TextField("Full Name", text: $fullName)
    .textInputAutocapitalization(.words)
    .keyboardType(.default)
    .textContentType(.name)
    .autocorrectionDisabled()
    .padding()
    .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1))

The key is pairing TextField with appropriate modifiers. keyboardType changes the on-screen keyboard—use .emailAddress for email fields, .numberPad for numeric input. textContentType enables autofill from the system (huge UX win for passwords and addresses). autocorrectionDisabled() prevents unwanted autocorrect in username or reference fields.

Toggle for boolean choices

swift
Toggle("Agree to Terms", isOn: $agreedToTerms)
    .toggleStyle(.switch)

Toggle("Dark Mode", isOn: $darkModeEnabled)
    .toggleStyle(.checkbox)

Toggles are straightforward but the style matters. Switch toggles are typical for preference settings; checkbox toggles work better in forms where you need multiple agreements. Always label toggles clearly—never rely on position to explain what you're toggling.

Picker for selections

swift
Picker("Country", selection: $selectedCountry) {
    ForEach(countries, id: \.self) { country in
        Text(country).tag(country)
    }
}
.pickerStyle(.menu)

Picker("Priority", selection: $priority) {
    ForEach(Priority.allCases, id: \.self) { p in
        Label(p.rawValue, systemImage: p.icon).tag(p)
    }
}
.pickerStyle(.segmented)

Choose your picker style based on the number of options. Menu pickers (dropdowns) work for large lists. Segmented pickers suit 2–4 related options. For small, frequently-changed values on team dashboards, we've had success with segmented; for country selection in onboarding, a menu keeps the form compact.

DatePicker for time input

swift
DatePicker("Birth Date", selection: $birthDate, displayedComponents: [.date])
    .datePickerStyle(.compact)

DatePicker("Appointment", selection: $appointmentTime, in: Date()..., displayedComponents: [.date, .hourAndMinute])
    .datePickerStyle(.graphical)

Constrain date ranges when relevant. If collecting appointment times, prevent past dates with in: Date().... Use .compact for forms where space is tight; .graphical on detailed booking screens where the calendar view helps users visualize the week ahead.

Validate before saving

Validation is where forms either feel sluggish or responsive. Real-time validation with visual feedback keeps users informed without being preachy.

swift
struct ValidatedFormView: View {
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var confirmPassword: String = ""
    
    private var emailIsValid: Bool {
        email.contains("@") && email.contains(".")
    }
    
    private var passwordIsValid: Bool {
        password.count >= 8
    }
    
    private var passwordsMatch: Bool {
        !password.isEmpty && password == confirmPassword
    }
    
    private var formIsValid: Bool {
        emailIsValid && passwordIsValid && passwordsMatch
    }
    
    var body: some View {
        Form {
            Section(header: Text("Account")) {
                TextField("Email", text: $email)
                    .keyboardType(.emailAddress)
                
                if !emailIsValid && !email.isEmpty {
                    Text("Invalid email format")
                        .font(.caption)
                        .foregroundColor(.red)
                }
                
                SecureField("Password", text: $password)
                
                if !passwordIsValid && !password.isEmpty {
                    Text("At least 8 characters required")
                        .font(.caption)
                        .foregroundColor(.red)
                }
                
                SecureField("Confirm Password", text: $confirmPassword)
                
                if !passwordsMatch && !confirmPassword.isEmpty {
                    Text("Passwords do not match")
                        .font(.caption)
                        .foregroundColor(.red)
                }
            }
            
            Button(action: submitForm) {
                Text("Sign Up")
                    .frame(maxWidth: .infinity)
            }
            .disabled(!formIsValid)
            .buttonStyle(.borderedProminent)
        }
    }
    
    private func submitForm() {
        // Submit logic
    }
}

This pattern computes validation state via computed properties, then conditionally renders error messages. The submit button is disabled until all checks pass. This approach is clean and avoids scattered validation logic across multiple @State variables.

In production e-commerce flows, we've learned that showing errors too early (as the user types) is annoying, but showing them only on blur or submit feels like the form is judging you. A middle ground: show errors after the field loses focus or after a short debounce. For critical fields like passwords, inline feedback as they type actually helps users know they're on track.

For server-side validation (email already registered, username taken), handle the response in an async task and show a single alert or banner rather than cluttering each field:

swift
@State private var serverError: String?

private func submitForm() {
    Task {
        do {
            try await authService.signup(email: email, password: password)
            // Navigate to next screen
        } catch let error as AuthError {
            serverError = error.message
        }
    }
}

Focus management with @FocusState

Guiding users through a form with smart focus management is subtle but makes forms feel professional. When a user taps a "Next" button, automatically moving focus to the next field removes friction.

swift
struct MultiStepFormView: View {
    @State private var firstName: String = ""
    @State private var lastName: String = ""
    @State private var email: String = ""
    
    enum FocusField {
        case firstName, lastName, email
    }
    
    @FocusState private var focusedField: FocusField?
    
    var body: some View {
        Form {
            Section(header: Text("Personal Info")) {
                TextField("First Name", text: $firstName)
                    .focused($focusedField, equals: .firstName)
                    .submitLabel(.next)
                    .onSubmit {
                        focusedField = .lastName
                    }
                
                TextField("Last Name", text: $lastName)
                    .focused($focusedField, equals: .lastName)
                    .submitLabel(.next)
                    .onSubmit {
                        focusedField = .email
                    }
                
                TextField("Email", text: $email)
                    .focused($focusedField, equals: .email)
                    .keyboardType(.emailAddress)
                    .submitLabel(.done)
                    .onSubmit {
                        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                    }
            }
        }
        .onAppear {
            focusedField = .firstName
        }
    }
}

The @FocusState macro tracks which field has focus. By binding each TextField with .focused(_:equals:), we can programmatically move focus. Pairing this with .submitLabel(.next) shows the keyboard's return key as "Next" instead of "Return," and .onSubmit handles the tap by shifting focus to the next field.

This pattern shines in data entry apps where users fill the same form repeatedly (order entry, lead intake). The keyboard never closes; users stay in flow, tapping next until the form is done. Last field gets .submitLabel(.done) and dismisses the keyboard when tapped.

On mobile, focus management also helps scroll the form to keep the active field visible. SwiftUI doesn't always do this automatically on complex layouts, so consider wrapping your form in a ScrollViewReader and using scrollTo when focus changes if the keyboard is cutting off field text.

Common mistakes

  • Ignoring keyboard types. Leaving keyboard type as .default for email or phone fields forces users to hunt for symbols. Always set keyboardType and textContentType.
  • Over-validating in real time. Showing "Password too short" while a user is still typing feels aggressive. Validate on blur or after a debounce, not on every keystroke.
  • No visual feedback for errors. A red error message that disappears after a second is easy to miss. Keep error text visible until the user corrects the field, or use border colors to signal problems.
  • Forgetting to disable submit during async operations. Users tap "Save" twice if there's no visual indication that the request is in flight. Disable buttons and show a loading state during network calls.
  • Not testing with external keyboards. iPad users often use hardware keyboards. Make sure .submitLabel and onSubmit work so they can tab through your form without a trackpad.
  • Cluttered forms with no sections. Dumping 20 fields in one section overwhelms users. Group logically and use sections to create breathing room.

Key takeaways

  • Use Form and Section to organize related fields and improve visual hierarchy and scrolling performance.
  • Match input types to their purpose: TextField with correct keyboardType and textContentType, Toggle for boolean choices, Picker for selections, DatePicker for time input.
  • Validate with computed properties; show errors after blur or submit, not on every keystroke, to avoid frustrating users.
  • Use @FocusState and .submitLabel to guide users through multi-field forms, automatically advancing focus on "Next" to keep them in flow.
  • Disable submit buttons during async operations and always provide visual loading feedback.
  • Test forms on iPad with hardware keyboards and on devices with different screen sizes to catch scroll and focus bugs early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles