BS
BleepingSwift
Published on
10 min read
Beginner

> SwiftUI Form: Sections, FormStyle, and Validation

Share:

// What_You_Will_Learn

  • Know when a Form is the right container and when a List is
  • Structure a form with Sections, headers and footers
  • Apply FormStyle and understand what each style changes
  • Use LabeledContent for read-only rows
  • Validate input without blocking the user mid-typing

Form is the container you reach for when a screen is mostly settings, preferences, or data entry. It has been in SwiftUI since iOS 13, but the interesting parts, the ones that let you control how it looks instead of accepting whatever the platform hands you, arrived with FormStyle in iOS 16.

The confusing thing about Form is that it looks like a styled List, behaves like a styled List on iOS, and yet is not one. Understanding where the two diverge is most of what you need.

Form vs List

A List presents a collection of rows. It has selection, swipe actions, onDelete, onMove, and it can be driven by an array of identifiable data. A Form presents a set of controls. It does not promise any of the collection behaviors, and it applies platform-appropriate styling to the controls inside it so a Toggle becomes a switch on the trailing edge, a Picker collapses to a navigation link, and labels line up with each other.

On iOS the visual difference is small because Form renders on top of a list under the hood. On macOS the difference is large. A List on macOS is a scrolling table with selection. A Form is a two-column layout of labels and values that does not scroll on its own. If you write a settings pane with List and run it on the Mac, it will look wrong in a way that no amount of padding fixes.

The practical rule is that dynamic content built from your model belongs in a List, and a fixed set of controls belongs in a Form.

Swift
struct ProfileForm: View {
    @State private var name = ""
    @State private var email = ""
    @State private var notifications = true

    var body: some View {
        Form {
            TextField("Name", text: $name)
            TextField("Email", text: $email)
                .keyboardType(.emailAddress)
                .textInputAutocapitalization(.never)
            Toggle("Email notifications", isOn: $notifications)
        }
    }
}

Nothing here says "switch" or "rounded rectangle." The Form decides that, and it decides differently per platform.

Sections, Headers, and Footers

A form with more than four or five rows needs grouping. Section takes a header, a footer, or both, and both accept arbitrary views rather than just strings.

Swift
Form {
    Section("Account") {
        TextField("Display name", text: $name)
        TextField("Email", text: $email)
    }

    Section {
        Toggle("Push notifications", isOn: $push)
        Toggle("Weekly digest", isOn: $digest)
    } header: {
        Text("Notifications")
    } footer: {
        Text("The digest arrives Sunday morning. You can turn it off at any time.")
    }
}

Footers are underused. They are the natural home for the sentence explaining what a toggle actually does, and putting it there keeps the row itself short instead of wrapping a paragraph into a control label.

Header rendering is one of the places where form style leaks through. In the grouped style on iOS, section headers are drawn small and uppercased. That transformation comes from the style, not from your string, so .textCase(nil) on the section is what restores your original capitalization:

Swift
Section {
    Toggle("Reduce motion", isOn: $reduceMotion)
} header: {
    Text("Accessibility")
        .textCase(nil)
}

If you want a header that reads as a real heading rather than a label, headerProminence(.increased) has been available since iOS 15 and gives you a larger, darker title.

FormStyle

.formStyle(_:) and the styles that come with it are iOS 16, iPadOS 16, macOS 13, tvOS 16, watchOS 9 and visionOS 1. There are three options in the standard library.

The default is .automatic, which resolves to whatever the platform considers normal. .grouped gives you grouped rows, the inset card look you know from Settings on iOS. .columns gives a non-scrolling layout with a trailing-aligned column of labels beside a leading-aligned column of values, which is the classic Mac preferences arrangement.

Swift
Form {
    Section("Server") {
        TextField("Host", text: $host)
        TextField("Port", text: $port)
    }
}
.formStyle(.grouped)

The reason to set .grouped explicitly on macOS is that it gets you the sectioned, scrolling appearance instead of the two-column one, which suits longer settings screens. On iOS the practical effect is much smaller since grouped is already what you get. Setting it anyway is cheap insurance if the same view is ever shared with a Mac target.

.columns is worth knowing about even if you never ship it on iPhone, because it explains why Form on the Mac ignores your Spacer and refuses to scroll. It is doing exactly what it says: laying out two columns, no scroll view involved.

LabeledContent for Read-Only Rows

Before iOS 16, showing a label on the left and a value on the right meant an HStack with a Spacer in the middle, which then failed to align with the rows around it. LabeledContent fixed that. It is iOS 16, macOS 13, watchOS 9 and visionOS 1, matching FormStyle.

Swift
Form {
    Section("About") {
        LabeledContent("Version", value: appVersion)
        LabeledContent("Build", value: buildNumber)
        LabeledContent("Storage used") {
            Text(usage, format: .byteCount(style: .file))
                .monospacedDigit()
        }
    }
}

The value form takes a string, and the closure form takes any view, which is how you get a formatted number, a badge, or a small button on the trailing side. Because it participates in the form's alignment, a column of LabeledContent rows lines up with the TextField and Toggle rows around it, which the HStack version never quite did.

Controls Inside a Form

Most of the standard controls change appearance once they are inside a Form, and it is usually the appearance you wanted. Toggle becomes a switch. Picker picks a style based on platform and content, and on iOS with a navigation stack around it, .pickerStyle(.navigationLink) pushes a full-screen list of options instead of showing a menu.

Swift
Form {
    Picker("Theme", selection: $theme) {
        ForEach(Theme.allCases, id: \.self) { theme in
            Text(theme.name).tag(theme)
        }
    }
    .pickerStyle(.navigationLink)

    DatePicker("Reminder", selection: $reminder, displayedComponents: .hourAndMinute)

    TextField("Notes", text: $notes, axis: .vertical)
        .lineLimit(3...8)
}

That last one is a small favorite. The axis: parameter on TextField is iOS 16 and lets a field grow vertically as the user types, with lineLimit(3...8) bounding how far it goes. It saves you from reaching for TextEditor, which does not inherit form row styling and needs its own padding fixes.

Validation Without Fighting the User

The tempting approach is to validate on every keystroke and paint the field red the moment it is not yet valid. That means an email field is red for the entire time someone is typing their email, which is both noisy and wrong.

A better shape is to compute validity as a derived property, gate the submit action on it, and only surface the error once the field has lost focus and is not empty. @FocusState (iOS 15) gives you the signal you need.

Swift
struct SignUpForm: View {
    @State private var email = ""
    @FocusState private var emailFocused: Bool

    private var emailIsValid: Bool {
        email.contains("@") && email.contains(".")
    }

    private var showEmailError: Bool {
        !emailFocused && !email.isEmpty && !emailIsValid
    }

    var body: some View {
        Form {
            Section {
                TextField("Email", text: $email)
                    .keyboardType(.emailAddress)
                    .textInputAutocapitalization(.never)
                    .focused($emailFocused)
            } footer: {
                if showEmailError {
                    Text("Enter a valid email address.")
                        .foregroundStyle(.red)
                }
            }

            Button("Create account") {
                submit()
            }
            .disabled(!emailIsValid)
        }
    }

    private func submit() {
        // ...
    }
}

Putting the message in the section footer rather than in a row of its own keeps the layout stable, because the footer grows and shrinks without pushing rows around the way an inserted row does. The disabled modifier on the button does the actual gating, and the message only explains why.

For anything more involved than a couple of fields, move the validity computation onto an @Observable model rather than growing a pile of @State booleans in the view. The view still just reads model.canSubmit.

Styling Gotchas

The one that catches everyone is trying to set a background. Form draws its own, so .background(Color.blue) on the form paints behind a surface that is already opaque and nothing appears to change. Rows are the same story. Use listRowBackground on the row and .scrollContentBackground(.hidden) on the form when you want your own color to show through.

Swift
Form {
    Section("Theme") {
        Toggle("Dark mode", isOn: $darkMode)
            .listRowBackground(Color.indigo.opacity(0.15))
    }
}
.scrollContentBackground(.hidden)
.background(Color.indigo.opacity(0.05))

Row insets are the second one. Padding a view inside a row adds to the row's own insets rather than replacing them, so a view that needs to reach the edges wants .listRowInsets(EdgeInsets()).

The third is the keyboard. A form scrolls, and by default scrolling does not dismiss the keyboard, which leaves a field covered while the user tries to reach the button below it. .scrollDismissesKeyboard(.interactively) is iOS 16 and fixes it in one line. If you need a Done button above the keyboard instead, a keyboard toolbar is the native way to add one.

Form is one of those APIs that rewards leaning into it. Almost every time a form looks wrong, the fix is to stop working around the container and let it do the layout, then adjust the specific row that needs to be different.

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.