How to Build a Settings Screen in SwiftUI
Wrap rows inside a Form and group them with Section views. SwiftUI automatically renders the familiar inset-grouped list style that matches the system Settings app.
struct SettingsView: View {
@AppStorage("notifications") private var notifications = true
@AppStorage("theme") private var theme = "System"
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
Picker("Theme", selection: $theme) {
Text("System").tag("System")
Text("Light").tag("Light")
Text("Dark").tag("Dark")
}
}
Section("Notifications") {
Toggle("Enable Notifications", isOn: $notifications)
}
}
.navigationTitle("Settings")
}
}
}
Full implementation
The example below builds a realistic settings screen with four sections: Appearance, Notifications, Account, and About. Each section demonstrates a different row type — Picker, Toggle, NavigationLink, and a plain label row — all bound to @AppStorage so preferences persist automatically across launches.
import SwiftUI
// MARK: - Settings Model
enum AppTheme: String, CaseIterable, Identifiable {
case system = "System"
case light = "Light"
case dark = "Dark"
var id: String { rawValue }
}
// MARK: - Sub-screen for Account
struct AccountSettingsView: View {
@AppStorage("username") private var username = "Ada Lovelace"
var body: some View {
Form {
Section("Profile") {
TextField("Display Name", text: $username)
.autocorrectionDisabled()
}
Section {
Button("Sign Out", role: .destructive) {
// handle sign-out
}
}
}
.navigationTitle("Account")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Main Settings Screen
struct SettingsView: View {
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
@AppStorage("notificationsOn") private var notificationsOn = true
@AppStorage("soundEnabled") private var soundEnabled = true
@AppStorage("badgeCount") private var badgeCount = true
@AppStorage("analyticsEnabled") private var analyticsEnabled = false
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
var body: some View {
NavigationStack {
Form {
// ── Appearance ────────────────────────────────────
Section {
Picker("Theme", selection: $appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue).tag(theme.rawValue)
}
}
} header: {
Text("Appearance")
}
// ── Notifications ─────────────────────────────────
Section {
Toggle("Enable Notifications", isOn: $notificationsOn)
Toggle("Sounds", isOn: $soundEnabled)
.disabled(!notificationsOn)
Toggle("Badge App Icon", isOn: $badgeCount)
.disabled(!notificationsOn)
} header: {
Text("Notifications")
} footer: {
Text("Badge and sound settings require notifications to be enabled.")
.font(.caption)
}
// ── Account ───────────────────────────────────────
Section("Account") {
NavigationLink("Account Settings") {
AccountSettingsView()
}
Toggle("Share Analytics", isOn: $analyticsEnabled)
}
// ── About ─────────────────────────────────────────
Section("About") {
LabeledContent("Version", value: appVersion)
Link("Privacy Policy",
destination: URL(string: "https://soarias.com/privacy")!)
Link("Terms of Service",
destination: URL(string: "https://soarias.com/terms")!)
}
}
.navigationTitle("Settings")
}
}
}
// MARK: - Preview
#Preview {
SettingsView()
}
How it works
-
Formas the root container.Formautomatically adopts the.insetGroupedlist style on iOS — you get the rounded, card-like section appearance of Apple's Settings app with zero extra modifiers. -
Sectionwith header and footer. Passing a string literal toSection("Notifications")creates a header. The trailingfooter:closure renders a caption below the group, ideal for explaining why certain rows are disabled. -
@AppStoragefor persistence. Every toggle and picker is bound directly to@AppStorage, which reads and writesUserDefaultsunder the hood. NoviewModelboilerplate is required for simple scalar preferences. -
.disabled(!notificationsOn)for dependent rows. The Sounds and Badge rows use.disabledtied to the parent toggle. SwiftUI grays them out and ignores taps automatically — no extraifbranching needed. -
NavigationLinkfor drill-down screens. WrappingAccountSettingsView()in aNavigationLinkinside aNavigationStackgives free push/pop navigation with the standard back button and title animation.
Variants
Add icons to rows with Label
Section("Notifications") {
Toggle(isOn: $notificationsOn) {
Label("Enable Notifications", systemImage: "bell.badge")
}
Toggle(isOn: $soundEnabled) {
Label("Sounds", systemImage: "speaker.wave.2")
}
Toggle(isOn: $badgeCount) {
Label("Badge App Icon", systemImage: "app.badge")
}
}
// Tint each icon independently
Label("Theme", systemImage: "paintbrush")
.foregroundStyle(.purple)
Present as a sheet instead of a pushed screen
If your app doesn't have a persistent NavigationStack, present SettingsView() modally. Wrap it in its own NavigationStack so the title and any sub-screens render correctly:
// In your parent view:
@State private var showSettings = false
Button("Settings") { showSettings = true }
.sheet(isPresented: $showSettings) {
NavigationStack { // ← required inside sheet
SettingsView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showSettings = false }
}
}
}
}
Common pitfalls
-
iOS version gotcha:
LabeledContentis iOS 16+, but theForm/Sectionheader/footer closure syntax requires iOS 15+. Everything in the example above compiles cleanly on iOS 17+, but double-check your deployment target if you support older versions. -
SwiftUI-specific gotcha: Placing a
NavigationStackinside aFormcell instead of around the whole screen breaks navigation. TheNavigationStackmust wrapSettingsViewfrom the outside — never from within a row. -
Performance gotcha:
@AppStoragetriggers a full view re-render on every write. For high-frequency updates (sliders, text fields mid-type) consider using a local@Stateand only writing to@AppStorageonSubmitoronChangewith a debounce. -
Accessibility gotcha:
ToggleandPickerrows inherit their accessibility label from the siblingTextview automatically. But customLabeledContentrows may need an explicit.accessibilityLabelmodifier so VoiceOver reads both the key and the value together.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a settings screen in SwiftUI for iOS 17+. Use Form and Section for grouped layout. Persist preferences with @AppStorage. Include Toggle, Picker, NavigationLink, and LabeledContent rows. Make it accessible (VoiceOver labels on every row). Add a #Preview with realistic sample data.
In Soarias, drop this prompt into the Build phase after your screens are scaffolded — Claude Code will wire up @AppStorage keys to match the rest of your data model and insert the settings sheet into your existing navigation flow automatically.
Related
FAQ
Does this work on iOS 16?
Form, Section, Toggle, Picker, and @AppStorage all exist since iOS 14. The only iOS 17-specific feature used above is the #Preview macro — replace it with a PreviewProvider struct to drop back to iOS 16. LabeledContent requires iOS 16+ specifically. For iOS 15 targets, swap it for a plain HStack { Text(key); Spacer(); Text(value) }.
How do I share settings values across multiple views without passing bindings everywhere?
@AppStorage with the same key in every view that needs it — SwiftUI keeps them in sync automatically since they all read the same UserDefaults slot. Second, create an @Observable class (Swift 5.9+) that exposes @AppStorage-backed properties, inject it with .environment(), and read it with @Environment. The observable approach is better when multiple preferences change together and you want atomic validation before saving.
What's the UIKit equivalent?
UITableViewController with style .insetGrouped, hand-coding sections as arrays and populating cells with UISwitch, UIStepper, or disclosure chevrons. You'd also manage didSelectRow callbacks and separate data sources manually. SwiftUI's Form/Section collapses all of that into declarative view composition, roughly cutting the boilerplate by 70–80%.
Last reviewed: 2026-05-11 by the Soarias team.