- Published on
- 10 min read Beginner
> SwiftUI Form: Sections, FormStyle, and Validation
// 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.
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.
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:
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.
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.
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.
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.
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.
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
// Continue_Learning
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
Adding a TextField to an Alert in SwiftUI
Learn how to accept user input in an alert using TextField or SecureField.
Adding a character limit to a TextEditor or TextField in SwiftUI
Learn how to create a modifier that limits the characters that can be entered in a TextEditor or TextField view in SwiftUI.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.