NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Settings Screen Tutorial: Build a Pro Settings Page in 2026

Complete guide to building a polished, production-ready settings screen in SwiftUI with Form, @AppStorage, subscription status, and design tokens.

Ahmed GaganAhmed Gagan
11 min read

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.
  • Section with headers and footers. SwiftUI's Form automatically styles sections with the grouped inset appearance. Footers provide context without cluttering the rows.
  • role: .destructive on 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.
  • LabeledContent for 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.
  • Link for external URLs. Opens Safari or the in-app browser. Do not use NavigationLink for 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:

  • .task modifier loads status on appear. This is the correct SwiftUI pattern for async work tied to view lifecycle. It cancels automatically when the view disappears.
  • managementURL from 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:

CriteriaForm (Recommended)List + SectionsCustom ScrollView
Apple HIG alignmentNative settings lookClose, needs stylingCustom — no alignment
Built-in Toggle/PickerAutomatic stylingRequires manual layoutFully manual
Section headers/footersBuilt-inBuilt-inManual
AccessibilityAutomaticMostly automaticManual implementation
Implementation time1-2 hours2-4 hours6-10 hours
Design flexibilityLow (intentionally)MediumFull control
Best forStandard app settingsSettings with custom rowsBranded/design-heavy UIs
Dark modeAutomaticMostly automaticManual 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.

ItemWhy It MattersPriority
Restore Purchases buttonApple rejects apps without it — most common subscription rejection reasonCritical
Privacy Policy linkRequired by App Store Review Guidelines and GDPRCritical
Terms of Service linkRequired for apps with subscriptions or user-generated contentCritical
Sign Out with confirmation alertPrevents accidental sign-out; destructive actions need confirmationCritical
App version and build numberEssential for customer support — users can report which version they are onHigh
Subscription status displayUsers should always know their current plan without guessingHigh
Manage Subscription linkDeep-links to Apple subscription management — Apple recommends thisHigh
Test in both light and dark modeForm sections and custom rows must look correct in both appearancesHigh
VoiceOver supportForm controls are accessible by default, but custom rows need labelsHigh
Dynamic Type supportSettings text must scale with the user's text size preferenceHigh
Delete Account optionRequired by Apple since 2022 for apps that support account creationHigh
Support/feedback linkGives frustrated users an outlet before they leave a 1-star reviewMedium
Rate the App linkDrives App Store ratings from your most engaged usersMedium
Share the App buttonOrganic growth from word-of-mouth — low effort, meaningful upsideMedium
Acknowledgements screenCredits open-source libraries; some licenses legally require itLow

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:

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.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit