TL;DR
Build a production-ready SwiftUI settings screen using Form with grouped sections for Account, Appearance, Notifications, Subscription Status, and About. Persist preferences with @AppStorage, show RevenueCat entitlement status, and style everything with design tokens for automatic dark mode support. Full code below. Or skip the build and grab the pre-built settings screen in The Swift Kit — it ships ready to customize.
Every iOS app needs a settings screen. It is where users manage their account, toggle preferences, check their subscription, and find support links. Yet most tutorials treat it as an afterthought — a dumping ground of random toggles with no structure. This guide shows you how to build a settings screen that feels as polished as Apple's own Settings app, with real code you can drop into your project today.
What Makes a Great iOS Settings Screen?
Before writing any code, it helps to study what Apple gets right in their own apps. Open the Settings app on your iPhone and notice the patterns:
- Grouped sections with clear headers. Account settings are separate from notification preferences. The visual grouping makes the screen scannable even with 15+ rows.
- Consistent row types. Toggles for boolean preferences, navigation links for detail screens, buttons for destructive actions. Users already know what each row type means because iOS trained them.
- Minimal text. Labels are 2-4 words. Descriptions appear as footers below sections, not inline with every row. The screen is scannable, not readable.
- Destructive actions at the bottom. Sign out and delete account sit at the very end, colored red, requiring confirmation. This follows Apple's Human Interface Guidelines for destructive actions.
- System-native appearance. The grouped inset list style, standard toggle controls, and platform fonts make the settings screen feel like part of iOS, not a custom UI that users need to learn.
Apple's HIG explicitly recommends using Form for settings interfaces. It provides the grouped list style, handles accessibility automatically, and gives you standard controls (Toggle, Picker, DatePicker) that behave exactly as users expect. Fighting this convention is almost never worth it.
How Do You Build a Settings Screen in SwiftUI?
Here is a complete, production-ready SwiftUI settings screen using Form with five sections. This is the exact structure I use in shipping apps — it covers the features 90% of apps need.
// SettingsView.swift
import SwiftUI
struct SettingsView: View {
// MARK: - Persisted preferences
@AppStorage("isDarkModeEnabled") private var isDarkModeEnabled = false
@AppStorage("accentColorName") private var accentColorName = "blue"
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("weeklyDigestEnabled") private var weeklyDigestEnabled = false
// MARK: - State
@State private var showSignOutAlert = false
@State private var showDeleteAccountAlert = false
// MARK: - Environment
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
// ── Account ──────────────────────────
Section {
NavigationLink {
EditProfileView()
} label: {
HStack(spacing: 14) {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Ahmed Gagan")
.font(.headline)
Text("ahmed@example.com")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
// ── Appearance ───────────────────────
Section("Appearance") {
Toggle("Dark Mode", isOn: $isDarkModeEnabled)
Picker("Accent Color", selection: $accentColorName) {
Text("Blue").tag("blue")
Text("Purple").tag("purple")
Text("Orange").tag("orange")
Text("Green").tag("green")
}
}
// ── Notifications ────────────────────
Section {
Toggle("Push Notifications", isOn: $notificationsEnabled)
Toggle("Weekly Digest", isOn: $weeklyDigestEnabled)
} header: {
Text("Notifications")
} footer: {
Text("Weekly digest sends a summary of your activity every Monday at 9 AM.")
}
// ── About ────────────────────────────
Section("About") {
LabeledContent("Version", value: appVersion)
LabeledContent("Build", value: buildNumber)
Link(destination: URL(string: "https://yourapp.com/privacy")!) {
Label("Privacy Policy", systemImage: "hand.raised")
}
Link(destination: URL(string: "https://yourapp.com/terms")!) {
Label("Terms of Service", systemImage: "doc.text")
}
NavigationLink {
AcknowledgementsView()
} label: {
Label("Acknowledgements", systemImage: "heart")
}
}
// ── Danger Zone ──────────────────────
Section {
Button("Sign Out", role: .destructive) {
showSignOutAlert = true
}
Button("Delete Account", role: .destructive) {
showDeleteAccountAlert = true
}
}
}
.navigationTitle("Settings")
.alert("Sign Out?", isPresented: $showSignOutAlert) {
Button("Cancel", role: .cancel) {}
Button("Sign Out", role: .destructive) {
// Call your auth service sign-out
AuthService.shared.signOut()
}
} message: {
Text("You will need to sign in again to access your account.")
}
.alert("Delete Account?", isPresented: $showDeleteAccountAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
// Call your account deletion endpoint
AuthService.shared.deleteAccount()
}
} message: {
Text("This action is permanent. All your data will be erased.")
}
}
}
// MARK: - Helpers
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—"
}
}Let me walk through the design decisions in this code:
- Account section at the top. The user's profile is the first thing they see — it anchors them and confirms they are in the right place. The navigation link leads to a detail screen for editing profile fields.
Sectionwith headers and footers. SwiftUI'sFormautomatically styles sections with the grouped inset appearance. Footers provide context without cluttering the rows.role: .destructiveon buttons. This tells SwiftUI to color the button red and, in alerts, positions it appropriately. Never manually set destructive buttons to red — use the semantic role.LabeledContentfor read-only values. Introduced in iOS 16, this is the correct way to show key-value pairs in a Form. It aligns the value to the trailing edge automatically.Linkfor external URLs. Opens Safari or the in-app browser. Do not useNavigationLinkfor external URLs — that would push a blank view onto your navigation stack.
How Do You Handle User Preferences with @AppStorage?
The settings screen above uses @AppStorage for all user preferences. This is the simplest persistence mechanism in SwiftUI and the right choice for settings-type data. Here is a deeper look at the pattern and its limitations.
// @AppStorage wraps UserDefaults with SwiftUI reactivity
@AppStorage("isDarkModeEnabled") private var isDarkModeEnabled = false
// The key "isDarkModeEnabled" maps to UserDefaults.standard
// Changing the value triggers a view update automatically
// The default value (false) is used when no stored value exists@AppStorage supports these types out of the box: Bool, Int, Double, String, URL, and Data. For custom types, conform to RawRepresentable where the raw value is one of those types. Here is an example with an accent color enum:
// AccentColorPreference.swift
enum AccentColorPreference: String, CaseIterable {
case blue, purple, orange, green
var color: Color {
switch self {
case .blue: return .blue
case .purple: return .purple
case .orange: return .orange
case .green: return .green
}
}
var displayName: String {
rawValue.capitalized
}
}
// Usage in settings
struct AppearanceSection: View {
@AppStorage("accentColor") private var accentColor: AccentColorPreference = .blue
var body: some View {
Section("Appearance") {
Picker("Accent Color", selection: $accentColor) {
ForEach(AccentColorPreference.allCases, id: \.self) { color in
HStack {
Circle()
.fill(color.color)
.frame(width: 16, height: 16)
Text(color.displayName)
}
.tag(color)
}
}
}
}
}To actually apply the dark mode preference across your entire app, set the preferredColorScheme modifier at the root:
// YourApp.swift
@main
struct YourApp: App {
@AppStorage("isDarkModeEnabled") private var isDarkModeEnabled = false
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(isDarkModeEnabled ? .dark : .light)
}
}
}When not to use @AppStorage: Do not store sensitive data (tokens, passwords) in @AppStorage — it writes to UserDefaults, which is not encrypted. Use Keychain for secrets. Also avoid it for large data blobs (images, JSON arrays) — use the file system or SwiftData instead. And if a preference needs to sync across devices, use @AppStorage with UserDefaults(suiteName:) paired with NSUbiquitousKeyValueStore, or store it in your Supabase backend.
How Do You Add a Subscription Status Section?
If your app has in-app purchases or subscriptions, the settings screen is where users expect to see their current plan and manage it. Here is how to integrate RevenueCat to display subscription status in your settings.
// SubscriptionSection.swift
import SwiftUI
import RevenueCat
struct SubscriptionSection: View {
@State private var isPro = false
@State private var expirationDate: Date?
@State private var managementURL: URL?
var body: some View {
Section {
// Plan status row
HStack {
Label {
Text("Current Plan")
} icon: {
Image(systemName: isPro ? "crown.fill" : "person.fill")
.foregroundStyle(isPro ? .yellow : .secondary)
}
Spacer()
Text(isPro ? "Pro" : "Free")
.font(.subheadline.weight(.semibold))
.foregroundStyle(isPro ? .accent : .secondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
isPro
? Color.accentColor.opacity(0.15)
: Color.secondary.opacity(0.15),
in: Capsule()
)
}
// Expiration date (Pro only)
if isPro, let expiration = expirationDate {
LabeledContent("Renews") {
Text(expiration, style: .date)
.foregroundStyle(.secondary)
}
}
// Upgrade button (Free only)
if !isPro {
Button {
// Present your paywall
NotificationCenter.default.post(
name: .showPaywall,
object: nil
)
} label: {
HStack {
Text("Upgrade to Pro")
.fontWeight(.semibold)
Spacer()
Image(systemName: "arrow.right")
}
.foregroundStyle(.accent)
}
}
// Manage subscription (Pro only)
if isPro, let url = managementURL {
Link(destination: url) {
Label("Manage Subscription", systemImage: "creditcard")
}
}
// Restore purchases
Button {
Task {
try? await Purchases.shared.restorePurchases()
await loadSubscriptionStatus()
}
} label: {
Label("Restore Purchases", systemImage: "arrow.clockwise")
}
} header: {
Text("Subscription")
} footer: {
if !isPro {
Text("Unlock all features, remove limits, and support development.")
}
}
.task {
await loadSubscriptionStatus()
}
}
private func loadSubscriptionStatus() async {
do {
let customerInfo = try await Purchases.shared.customerInfo()
isPro = customerInfo.entitlements["pro"]?.isActive == true
expirationDate = customerInfo.entitlements["pro"]?.expirationDate
managementURL = customerInfo.managementURL
} catch {
// Handle error — default to free
isPro = false
}
}
}Key details in this implementation:
.taskmodifier loads status on appear. This is the correct SwiftUI pattern for async work tied to view lifecycle. It cancels automatically when the view disappears.managementURLfrom RevenueCat. This deep-links to the Apple subscription management page where the user can cancel, change plan, or update payment. Never try to build your own cancellation flow — Apple requires you to use their system.- Restore purchases button is mandatory. Apple rejects apps that do not provide a way to restore purchases. This is one of the most common App Store rejection reasons for subscription apps.
- The upgrade button posts a notification to show the paywall. You could also use a binding, environment value, or coordinator pattern — the point is that the settings screen triggers the paywall but does not own it.
Drop this SubscriptionSection into your main settings Form between the Appearance and About sections. In The Swift Kit, this is already wired up with the RevenueCat integration and the paywall — it works out of the box.
How Do Settings Screen Patterns Compare?
There are three common approaches to building settings in SwiftUI. Here is how they stack up:
| Criteria | Form (Recommended) | List + Sections | Custom ScrollView |
|---|---|---|---|
| Apple HIG alignment | Native settings look | Close, needs styling | Custom — no alignment |
| Built-in Toggle/Picker | Automatic styling | Requires manual layout | Fully manual |
| Section headers/footers | Built-in | Built-in | Manual |
| Accessibility | Automatic | Mostly automatic | Manual implementation |
| Implementation time | 1-2 hours | 2-4 hours | 6-10 hours |
| Design flexibility | Low (intentionally) | Medium | Full control |
| Best for | Standard app settings | Settings with custom rows | Branded/design-heavy UIs |
| Dark mode | Automatic | Mostly automatic | Manual for every element |
My recommendation: start with Form unless you have a specific design requirement that demands a custom layout. Form gives you the native settings appearance, built-in accessibility, automatic dark mode, and standard control styling for free. If you later need a branded settings page (common in consumer apps like Spotify or Instagram), migrate to a custom ScrollView — but most indie apps never need to.
The List approach sits in the middle — it gives you section support and some automatic styling but requires more manual work for toggles and pickers to look right. It is useful when you need custom row layouts (like the subscription status section above) mixed with standard controls. In practice, you can embed custom HStack layouts inside a Form section and get the same result with less code.
How Do You Link Settings to Your Design System?
A common mistake is styling the settings screen independently from the rest of your app. If your onboarding uses one shade of blue and your settings uses another, users notice the inconsistency. The fix is design tokens — centralized constants for every visual property.
// DesignSystem.swift — single source of truth
struct DS {
// Colors
static let primary = Color("PrimaryColor") // Asset catalog
static let accent = Color("AccentColor")
static let surfaceCard = Color("SurfaceCard")
static let textPrimary = Color("TextPrimary")
static let textSecondary = Color("TextSecondary")
static let destructive = Color.red
// Spacing
static let spacingSM: CGFloat = 8
static let spacingMD: CGFloat = 16
static let spacingLG: CGFloat = 24
// Radii
static let radiusSM: CGFloat = 8
static let radiusMD: CGFloat = 12
static let radiusLG: CGFloat = 16
}
// Usage in the settings screen
struct ThemedSettingsView: View {
@AppStorage("isDarkModeEnabled") private var isDarkModeEnabled = false
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
Toggle(isOn: $isDarkModeEnabled) {
Label {
Text("Dark Mode")
.foregroundStyle(DS.textPrimary)
} icon: {
Image(systemName: "moon.fill")
.foregroundStyle(DS.accent)
}
}
.tint(DS.accent) // Toggle color matches your brand
}
}
.scrollContentBackground(.hidden)
.background(DS.surfaceCard.opacity(0.5))
.navigationTitle("Settings")
}
}
}The key insight: .tint(DS.accent) on a Toggle changes the switch color to match your brand. .scrollContentBackground(.hidden) lets you replace the default Form background with your design system's surface color. These two lines are enough to make a standard Form feel custom without building everything from scratch.
In The Swift Kit, the entire design system lives in a single DesignSystem.swift file. Change two hex values — primaryHex and accentHex — and every screen in the app updates, including settings. This is the design token approach that scales from 5 screens to 50 without inconsistency.
What Should Every Settings Screen Best Practices Checklist Include?
Here is the checklist I run through before shipping any settings screen. Every item comes from a real mistake I have seen (or made myself) in production apps.
| Item | Why It Matters | Priority |
|---|---|---|
| Restore Purchases button | Apple rejects apps without it — most common subscription rejection reason | Critical |
| Privacy Policy link | Required by App Store Review Guidelines and GDPR | Critical |
| Terms of Service link | Required for apps with subscriptions or user-generated content | Critical |
| Sign Out with confirmation alert | Prevents accidental sign-out; destructive actions need confirmation | Critical |
| App version and build number | Essential for customer support — users can report which version they are on | High |
| Subscription status display | Users should always know their current plan without guessing | High |
| Manage Subscription link | Deep-links to Apple subscription management — Apple recommends this | High |
| Test in both light and dark mode | Form sections and custom rows must look correct in both appearances | High |
| VoiceOver support | Form controls are accessible by default, but custom rows need labels | High |
| Dynamic Type support | Settings text must scale with the user's text size preference | High |
| Delete Account option | Required by Apple since 2022 for apps that support account creation | High |
| Support/feedback link | Gives frustrated users an outlet before they leave a 1-star review | Medium |
| Rate the App link | Drives App Store ratings from your most engaged users | Medium |
| Share the App button | Organic growth from word-of-mouth — low effort, meaningful upside | Medium |
| Acknowledgements screen | Credits open-source libraries; some licenses legally require it | Low |
Notice that six items are marked Critical or High because Apple requires them during App Store review. Skipping any of them is a guaranteed rejection. The medium-priority items are growth levers — they do not block submission but they meaningfully improve your app's success.
How Do You Put It All Together?
Here is the final, complete settings view that combines every section — account, appearance, subscription, notifications, and about — into a single production-ready file:
// CompleteSettingsView.swift
import SwiftUI
import RevenueCat
import StoreKit
struct CompleteSettingsView: View {
// Persisted preferences
@AppStorage("isDarkModeEnabled") private var isDarkModeEnabled = false
@AppStorage("accentColor") private var accentColor: AccentColorPreference = .blue
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("weeklyDigestEnabled") private var weeklyDigestEnabled = false
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
// Subscription state
@State private var isPro = false
@State private var expirationDate: Date?
@State private var managementURL: URL?
// Alerts
@State private var showSignOutAlert = false
@State private var showDeleteAlert = false
var body: some View {
NavigationStack {
Form {
// ── Account ─────────────────────
Section {
NavigationLink {
EditProfileView()
} label: {
AccountRow()
}
}
// ── Subscription ────────────────
Section {
SubscriptionStatusRow(isPro: isPro)
if isPro, let expiration = expirationDate {
LabeledContent("Renews") {
Text(expiration, style: .date)
.foregroundStyle(.secondary)
}
}
if !isPro {
Button {
NotificationCenter.default.post(
name: .showPaywall, object: nil
)
} label: {
Label("Upgrade to Pro", systemImage: "crown")
.fontWeight(.semibold)
.foregroundStyle(.accent)
}
}
if isPro, let url = managementURL {
Link(destination: url) {
Label("Manage Subscription",
systemImage: "creditcard")
}
}
Button {
Task {
try? await Purchases.shared.restorePurchases()
await loadSubscription()
}
} label: {
Label("Restore Purchases",
systemImage: "arrow.clockwise")
}
} header: {
Text("Subscription")
}
// ── Appearance ──────────────────
Section("Appearance") {
Toggle(isOn: $isDarkModeEnabled) {
Label("Dark Mode", systemImage: "moon.fill")
}
Picker(selection: $accentColor) {
ForEach(AccentColorPreference.allCases,
id: \.self) { c in
Label(c.displayName,
systemImage: "circle.fill")
.foregroundStyle(c.color)
.tag(c)
}
} label: {
Label("Accent Color",
systemImage: "paintpalette")
}
}
// ── Notifications ───────────────
Section {
Toggle(isOn: $notificationsEnabled) {
Label("Push Notifications",
systemImage: "bell.fill")
}
Toggle(isOn: $weeklyDigestEnabled) {
Label("Weekly Digest",
systemImage: "envelope.fill")
}
} header: {
Text("Notifications")
} footer: {
Text("Weekly digest sends a summary every Monday at 9 AM.")
}
// ── About ───────────────────────
Section("About") {
LabeledContent("Version", value: appVersion)
LabeledContent("Build", value: buildNumber)
Link(destination: URL(
string: "https://yourapp.com/privacy"
)!) {
Label("Privacy Policy",
systemImage: "hand.raised")
}
Link(destination: URL(
string: "https://yourapp.com/terms"
)!) {
Label("Terms of Service",
systemImage: "doc.text")
}
Button {
if let scene = UIApplication.shared
.connectedScenes.first as? UIWindowScene {
SKStoreReviewController
.requestReview(in: scene)
}
} label: {
Label("Rate on App Store",
systemImage: "star.fill")
}
ShareLink(
item: URL(
string: "https://apps.apple.com/app/idYOUR_ID"
)!
) {
Label("Share the App",
systemImage: "square.and.arrow.up")
}
}
// ── Danger Zone ─────────────────
Section {
Button("Sign Out", role: .destructive) {
showSignOutAlert = true
}
Button("Delete Account", role: .destructive) {
showDeleteAlert = true
}
}
// ── Debug (development only) ────
#if DEBUG
Section("Debug") {
Button("Reset Onboarding") {
hasCompletedOnboarding = false
}
Button("Clear All Preferences") {
if let domain = Bundle.main.bundleIdentifier {
UserDefaults.standard
.removePersistentDomain(forName: domain)
}
}
}
#endif
}
.navigationTitle("Settings")
.tint(accentColor.color)
.alert("Sign Out?", isPresented: $showSignOutAlert) {
Button("Cancel", role: .cancel) {}
Button("Sign Out", role: .destructive) {
AuthService.shared.signOut()
}
} message: {
Text("You will need to sign in again.")
}
.alert("Delete Account?", isPresented: $showDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
AuthService.shared.deleteAccount()
}
} message: {
Text("This is permanent. All data will be erased.")
}
.task { await loadSubscription() }
}
}
// MARK: - Helpers
private var appVersion: String {
Bundle.main.infoDictionary?[
"CFBundleShortVersionString"
] as? String ?? "—"
}
private var buildNumber: String {
Bundle.main.infoDictionary?[
"CFBundleVersion"
] as? String ?? "—"
}
private func loadSubscription() async {
do {
let info = try await Purchases.shared.customerInfo()
isPro = info.entitlements["pro"]?.isActive == true
expirationDate = info.entitlements["pro"]?.expirationDate
managementURL = info.managementURL
} catch {
isPro = false
}
}
}This is roughly 200 lines of SwiftUI for a settings screen that covers accounts, subscriptions, appearance, notifications, about info, and destructive actions — with proper alerts, accessibility, and dark mode baked in. In a shipping app, you would extract each section into its own view file for maintainability, but the structure stays the same.
Notice the #if DEBUG section at the bottom. This is invaluable during development: a "Reset Onboarding" button lets you replay your onboarding flow without deleting the app, and "Clear All Preferences" resets every @AppStorage value at once. These buttons are automatically stripped from release builds.
Get the Pre-Built Settings Screen in The Swift Kit
Every code example in this tutorial is already built, tested, and shipping in The Swift Kit. The settings screen comes fully wired with:
- Account section connected to Sign in with Apple and Supabase auth
- Subscription status powered by RevenueCat with automatic entitlement checks
- Appearance controls tied to the centralized design token system
- Push notification toggles that coordinate with the notification service
- Clean MVVM architecture with dependency injection so every section is testable and previewable
- Navigation that integrates with the app-wide coordinator pattern
You get the settings screen plus onboarding templates, paywall designs, AI features, analytics, and everything else you need to go from zero to App Store in weeks, not months. One-time purchase, lifetime updates, full source code.
Get The Swift Kit and stop rebuilding the same settings screen for every project.