← All lessonsLesson 36 of 57 · Part 10: Data, persistence, and networking
Lesson 36

Settings that are still there tomorrow

AppStorage and SceneStorage

You will buildThe Settings tab's choices kept in UserDefaults with @AppStorage, so that the traveler's name and the highlight survive a restart — and the tab you were on kept for the window with @SceneStorage, with the harness showing when the system brings it back and when it does not. Then Part 10's interview problem.

TimeAbout 50 minutes, then the interview problem

WhyLesson 33 put the trips in a file and lesson 35 put the packing lists in SwiftData. Small things need somewhere too: a preference the person chose, the tab they were on. SwiftUI has a property wrapper for each. @AppStorage keeps a value in UserDefaults, the same place NSUserDefaults kept it in your UIKit apps, and redraws the views that read it when it changes. @SceneStorage keeps interface state for one window, for the system to restore — the job of encodeRestorableState(with:) and restoration identifiers in UIKit.

Step 1

Open your project

  1. Open your Trips project as lesson 35 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.

  2. If you did not finish lesson 35, or would rather start from a known state, download Trips-lesson-35.zip instead. It is the project exactly as it stood at the end of lesson 35. Unzip it and open Trips.xcodeproj.

  3. Run it (⌘R) to check that it builds. You should see where lesson 35 ended:

    Trips, as it stood at the end of lesson 35 — screenshot
    Trips, as it stood at the end of lesson 35Screenshot · iPhone 17, iOS 27.0
Step 2

Forgotten at every launch

Run the app, open the Settings tab, type a name — Kim, say — and choose the mint highlight. Then stop the app in Xcode (Stop, ⌘.), run it again (⌘R), and open Settings:

After Stop and Run: no name, and the yellow highlight again — screenshot
After Stop and Run: no name, and the yellow highlight againScreenshot · iPhone 17, iOS 27.0

Everything is back to how it started. Since lesson 30 the settings have lived in an @Observable object made when the app starts, and nothing ever wrote them anywhere.

Step 3

The look, kept in UserDefaults

@AppStorage is "A property wrapper type that reflects a value from UserDefaults and invalidates a view on a change in value in that user default." You give it a key and a default — @AppStorage("showsYear") private var showsYear = true — and from then on reading it reads UserDefaults, setting it writes there at once, and every view that uses the same key redraws. It holds a fixed list of types — Bool, Int, Double, String, URL, Data, Date, their optionals, and enums whose raw values are strings or integers, such as the highlight. SwiftUI declares an initializer for each, and nothing else.

Which settings belong there? Apple's page for UserDefaults is plain about it: "Don’t store personal or sensitive information as settings. The defaults system stores information on disk in an unencrypted format. Store personal or sensitive information in the person’s Keychain instead." The highlight and the year on the Overview are preferences, and go to @AppStorage. The traveler's name is personal: it stays where it is, in the Settings object, for this session only. (The keychain has an API of its own, which this course does not cover.) So the Settings class keeps the name, and gains the two keys, written once so that no view can misspell one. In Settings.swift:

In Settings.swift, replace the Settings class with this
import SwiftUI/// The choices made on the Settings tab, shared by every screen that uses them. `@MainActor`: the/// screens read them./// The choices made on the Settings tab. The look is kept in UserDefaults by @AppStorage, under the keys below;/// the traveler's name is personal, so it is not. `@MainActor`: the screens read them.@MainActor@Observablefinal class Settings {    var travelerName = ""    var highlight = Highlight.yellow    var showsYear = true    /// The keys of the choices kept in UserDefaults, in one place, so that no view misspells one.    static let highlightKey = "highlight"    static let showsYearKey = "showsYear"    /// The colors the app can be drawn in. `Sendable`: plain data — and a type nested in the class    /// does not take the class's actor, so it says so itself.    enum Highlight: String, CaseIterable, Identifiable, Sendable {        case yellow, mint, orange        var id: Self {            self        }        var color: Color {            switch self {            case .yellow: .yellow            case .mint: .mint            case .orange: .orange            }        }    }}
import SwiftUI

/// The choices made on the Settings tab. The look is kept in UserDefaults by @AppStorage, under the keys below;
/// the traveler's name is personal, so it is not. `@MainActor`: the screens read them.
@MainActor
@Observable
final class Settings {
    var travelerName = ""

    /// The keys of the choices kept in UserDefaults, in one place, so that no view misspells one.
    static let highlightKey = "highlight"
    static let showsYearKey = "showsYear"

    /// The colors the app can be drawn in. `Sendable`: plain data — and a type nested in the class
    /// does not take the class's actor, so it says so itself.
    enum Highlight: String, CaseIterable, Identifiable, Sendable {
        case yellow, mint, orange

        var id: Self {
            self
        }

        var color: Color {
            switch self {
            case .yellow: .yellow
            case .mint: .mint
            case .orange: .orange
            }
        }
    }
}

The Settings screen binds the picker and the toggle to @AppStorage properties of its own, with $ as with @State; the name field still binds to the object. In SettingsView.swift:

In SettingsView.swift, replace the SettingsView struct with this
struct SettingsView: View {    @Environment(Settings.self) private var settings    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow    @AppStorage(Settings.showsYearKey) private var showsYear = true    var body: some View {        @Bindable var settings = settings        NavigationStack {            Form {                Section("Traveler") {                    TextField("Name", text: $settings.travelerName)                        .textContentType(.name)                        .submitLabel(.done)                }                Section("Look") {                    Picker("Highlight", selection: $settings.highlight) {                    Picker("Highlight", selection: $highlight) {                        ForEach(Settings.Highlight.allCases) { choice in                            Text(choice.rawValue.capitalized)                        }                    }                    .pickerStyle(.segmented)                    Toggle("Show the year on the Overview", isOn: $settings.showsYear)                    Toggle("Show the year on the Overview", isOn: $showsYear)                }            }            .navigationTitle("Settings")        }    }}
struct SettingsView: View {
    @Environment(Settings.self) private var settings
    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow
    @AppStorage(Settings.showsYearKey) private var showsYear = true

    var body: some View {
        @Bindable var settings = settings
        NavigationStack {
            Form {
                Section("Traveler") {
                    TextField("Name", text: $settings.travelerName)
                        .textContentType(.name)
                        .submitLabel(.done)
                }
                Section("Look") {
                    Picker("Highlight", selection: $highlight) {
                        ForEach(Settings.Highlight.allCases) { choice in
                            Text(choice.rawValue.capitalized)
                        }
                    }
                    .pickerStyle(.segmented)
                    Toggle("Show the year on the Overview", isOn: $showsYear)
                }
            }
            .navigationTitle("Settings")
        }
    }
}

The app reads the highlight to give every screen its color. In TripsApp.swift:

In TripsApp.swift, replace the TripsApp struct with this
import SwiftUIimport SwiftData@mainstruct TripsApp: App {    @Environment(\.scenePhase) private var scenePhase    @State private var store = TripStore()    @State private var settings = Settings()    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow    var body: some Scene {        WindowGroup {            TabView {                Tab("Overview", systemImage: "square.grid.2x2") {                    ContentView()                }                Tab("Trips", systemImage: "list.bullet") {                    TripList()                }                Tab("Settings", systemImage: "gearshape") {                    SettingsView()                }            }            .environment(store)            .environment(settings)            .environment(\.highlight, settings.highlight.color)            .environment(\.highlight, highlight.color)            .task {                // On the main actor, as the store is: `.task` and the closure take                // the actor of the code they are written in. Each value is a [Trip],                // handed on because a trip is Sendable.                for await _ in Observations({ store.trips }) {                    store.save()                }            }        }        .modelContainer(for: PackingItem.self)        .onChange(of: scenePhase) { _, new in            if new == .background {                store.save()            }        }    }}
import SwiftUI
import SwiftData

@main
struct TripsApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var store = TripStore()
    @State private var settings = Settings()
    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow

    var body: some Scene {
        WindowGroup {
            TabView {
                Tab("Overview", systemImage: "square.grid.2x2") {
                    ContentView()
                }
                Tab("Trips", systemImage: "list.bullet") {
                    TripList()
                }
                Tab("Settings", systemImage: "gearshape") {
                    SettingsView()
                }
            }
            .environment(store)
            .environment(settings)
            .environment(\.highlight, highlight.color)
            .task {
                // On the main actor, as the store is: `.task` and the closure take
                // the actor of the code they are written in. Each value is a [Trip],
                // handed on because a trip is Sendable.
                for await _ in Observations({ store.trips }) {
                    store.save()
                }
            }
        }
        .modelContainer(for: PackingItem.self)
        .onChange(of: scenePhase) { _, new in
            if new == .background {
                store.save()
            }
        }
    }
}

And the Overview reads whether to show the year — so it needs the Settings object no more, and neither do its two previews. In ContentView.swift:

In ContentView.swift, replace the ContentView struct with this
struct ContentView: View {    @Environment(TripStore.self) private var store    @Environment(Settings.self) private var settings    @AppStorage(Settings.showsYearKey) private var showsYear = true    @State private var filter = TripFilter.all    @State private var closings = 0    var body: some View {        @Bindable var store = store        ScrollView {            LazyVStack(spacing: 12) {                TripPlanner()                    .environment(\.highlight, .mint)                PhotoStrip(trips: store.trips)                TripSummary(trips: store.trips)                if settings.showsYear {                if showsYear {                    YearGrid(trips: store.trips)                }                FilterBar(filters: filters, selection: $filter) { trips in                    store.makeFavorites(trips)                }                    .frame(maxWidth: .infinity, alignment: .leading)                    .padding(.horizontal)                HStack {                    Menu("Sort", systemImage: "arrow.up.arrow.down") {                        Button("By date") { store.trips.sort { $0.start < $1.start } }                        Button("By price") { store.trips.sort { $0.price < $1.price } }                    }                    Spacer()                    Button("Close all", systemImage: "chevron.up") { closings += 1 }                }                .padding(.horizontal)                LazyVGrid(columns: [GridItem(.adaptive(minimum: 300))], spacing: 12) {                    ForEach($store.trips.filter { filter.includes($0.wrappedValue) }) { $trip in                        TripCard(trip: $trip)                    }                    .reorderable()                }                .reorderContainer(for: Trip.self, isEnabled: filter == .all) { difference in                    switch difference.destination.position {                    case .before(let id): store.move(difference.sources, before: id)                    case .end: store.move(difference.sources, before: nil)                    }                }                .id(closings)            }        }        .sensoryFeedback(.success, trigger: store.trips.filter(\.isFavorite).count)    }    /// All, Favorites, and then a filter for each country that has a trip.    private var filters: [TripFilter] {        var countries: [String] = []        for trip in store.trips where !countries.contains(trip.country) {            countries.append(trip.country)        }        return [.all, .favorites] + countries.map { TripFilter.country($0) }    }}
struct ContentView: View {
    @Environment(TripStore.self) private var store
    @AppStorage(Settings.showsYearKey) private var showsYear = true
    @State private var filter = TripFilter.all
    @State private var closings = 0

    var body: some View {
        @Bindable var store = store
        ScrollView {
            LazyVStack(spacing: 12) {
                TripPlanner()
                    .environment(\.highlight, .mint)
                PhotoStrip(trips: store.trips)
                TripSummary(trips: store.trips)
                if showsYear {
                    YearGrid(trips: store.trips)
                }
                FilterBar(filters: filters, selection: $filter) { trips in
                    store.makeFavorites(trips)
                }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal)
                HStack {
                    Menu("Sort", systemImage: "arrow.up.arrow.down") {
                        Button("By date") { store.trips.sort { $0.start < $1.start } }
                        Button("By price") { store.trips.sort { $0.price < $1.price } }
                    }
                    Spacer()
                    Button("Close all", systemImage: "chevron.up") { closings += 1 }
                }
                .padding(.horizontal)
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 300))], spacing: 12) {
                    ForEach($store.trips.filter { filter.includes($0.wrappedValue) }) { $trip in
                        TripCard(trip: $trip)
                    }
                    .reorderable()
                }
                .reorderContainer(for: Trip.self, isEnabled: filter == .all) { difference in
                    switch difference.destination.position {
                    case .before(let id): store.move(difference.sources, before: id)
                    case .end: store.move(difference.sources, before: nil)
                    }
                }
                .id(closings)
            }
        }
        .sensoryFeedback(.success, trigger: store.trips.filter(\.isFavorite).count)
    }

    /// All, Favorites, and then a filter for each country that has a trip.
    private var filters: [TripFilter] {
        var countries: [String] = []
        for trip in store.trips where !countries.contains(trip.country) {
            countries.append(trip.country)
        }
        return [.all, .favorites] + countries.map { TripFilter.country($0) }
    }
}
In ContentView.swift, replace the first preview with this
#Preview {    ContentView()        .environment(TripStore())        .environment(Settings())}
#Preview {
    ContentView()
        .environment(TripStore())
}
In ContentView.swift, replace the “Landscape” preview with this
#Preview("Landscape", traits: .landscapeLeft) {    ContentView()        .environment(TripStore())        .environment(Settings())}
#Preview("Landscape", traits: .landscapeLeft) {
    ContentView()
        .environment(TripStore())
}

Run the app and do the same again: a name, the mint highlight, Stop, Run, and Settings:

After Stop and Run: mint kept, the name not — screenshot
After Stop and Run: mint kept, the name notScreenshot · iPhone 17, iOS 27.0

Mint survived — read back from UserDefaults as the app started, by the Settings screen and by TripsApp, which hands it to every view that uses the highlight — and the name did not, as intended. The highlight was stored the moment it was chosen, which is why stopping the app straight away made no difference.

Step 4

The tab you were on

Which tab is in front is not a preference; it is where the person was. That is scene state: on an iPad with two windows of Trips, each has its own. @SceneStorage is "A property wrapper type that reads and writes to persisted, per-scene storage," and the system restores it: "its initial value is restored by the system if it was previously saved." It belongs in a view of the scene — not in the App — so the tabs move into a view of their own, and the TabView gets a selection: each Tab a value, and the selected value kept in scene storage. Make a new SwiftUI View named MainTabs, and replace everything in it:

New fileMainTabs.swift — the whole file
import SwiftUI

/// The app's three tabs. The one in front is scene storage: the system keeps it for this window.
struct MainTabs: View {
    @SceneStorage("tab") private var tab = "overview"

    var body: some View {
        TabView(selection: $tab) {
            Tab("Overview", systemImage: "square.grid.2x2", value: "overview") {
                ContentView()
            }
            Tab("Trips", systemImage: "list.bullet", value: "trips") {
                TripList()
            }
            Tab("Settings", systemImage: "gearshape", value: "settings") {
                SettingsView()
            }
        }
    }
}

And the app shows it. In TripsApp.swift:

In TripsApp.swift, replace the TripsApp struct with this
import SwiftUIimport SwiftData@mainstruct TripsApp: App {    @Environment(\.scenePhase) private var scenePhase    @State private var store = TripStore()    @State private var settings = Settings()    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow    var body: some Scene {        WindowGroup {            TabView {                Tab("Overview", systemImage: "square.grid.2x2") {                    ContentView()                }                Tab("Trips", systemImage: "list.bullet") {                    TripList()                }                Tab("Settings", systemImage: "gearshape") {                    SettingsView()                }            }            MainTabs()            .environment(store)            .environment(settings)            .environment(\.highlight, highlight.color)            .task {                // On the main actor, as the store is: `.task` and the closure take                // the actor of the code they are written in. Each value is a [Trip],                // handed on because a trip is Sendable.                for await _ in Observations({ store.trips }) {                    store.save()                }            }        }        .modelContainer(for: PackingItem.self)        .onChange(of: scenePhase) { _, new in            if new == .background {                store.save()            }        }    }}
import SwiftUI
import SwiftData

@main
struct TripsApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var store = TripStore()
    @State private var settings = Settings()
    @AppStorage(Settings.highlightKey) private var highlight = Settings.Highlight.yellow

    var body: some Scene {
        WindowGroup {
            MainTabs()
            .environment(store)
            .environment(settings)
            .environment(\.highlight, highlight.color)
            .task {
                // On the main actor, as the store is: `.task` and the closure take
                // the actor of the code they are written in. Each value is a [Trip],
                // handed on because a trip is Sendable.
                for await _ in Observations({ store.trips }) {
                    store.save()
                }
            }
        }
        .modelContainer(for: PackingItem.self)
        .onChange(of: scenePhase) { _, new in
            if new == .background {
                store.save()
            }
        }
    }
}

Run the app, tap Trips, then stop the app and run it again:

Stopped while on Trips: back on the Overview — screenshot
Stopped while on Trips: back on the OverviewScreenshot · iPhone 17, iOS 27.0

Back on the Overview. Nothing is wrong with the code: Apple says "The system makes no guarantees as to when and how often the data will be persisted," and in practice scene storage is saved when the app goes to the background. A stop in Xcode — like a crash — comes before that.

Run the app, tap Trips, and this time press Home first (in Device Hub, Controls ▸ Home). Then stop the app, and run it again:

Home first, then stopped: Trips again — screenshot
Home first, then stopped: Trips againScreenshot · iPhone 17, iOS 27.0

Trips, where you left it. That is the case scene storage is for: the person leaves the app, the system ends it later to free memory, and when they come back it looks as it did. Apple adds one more rule: "If the Scene is explicitly destroyed (e.g. the switcher snapshot is destroyed on iPadOS or the window is closed on macOS), the data is also destroyed." Closing a window on purpose means starting again.

What you built

That is Part 10. Below the cards and the edge cases is its interview problem. Next, Part 11 begins: Swift concurrency, starting with structured concurrency.

The finished project

Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.

Flash cards

16 cards for this lesson, each labelled with its topic. 9 ask how you would write something; the other 7 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.

Syntax · @AppStorageKeep a user's preference in UserDefaults, and redraw the view when it changes.
@AppStorage("compactRows") private var compactRows = false
…
Toggle("Compact rows", isOn: $compactRows)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

@AppStorage("key") with a default. Bind to it with $, as with @State; each change goes straight to UserDefaults: "UserDefaults says: true".

Syntax · @AppStorageKeep an enum in @AppStorage.
import SwiftUI

enum Highlight: String, CaseIterable {
    case yellow, mint, orange
}
…
@AppStorage("highlight") private var highlight = Highlight.yellow
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Give the enum a String or Int raw value: @AppStorage stores the raw value — "stored: mint".

Syntax · @SceneStorageRemember which tab was in front, for each window.
@SceneStorage("tab") private var tab = "overview"
…
TabView(selection: $tab) {
    Tab("Overview", systemImage: "square.grid.2x2", value: "overview") { Text("The overview") }
    Tab("Trips", systemImage: "list.bullet", value: "trips") { Text("The trips") }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

@SceneStorage("key") in a view of the scene, with the TabView bound to it and each Tab given a value. The picture: Trips chosen, the app sent to the background, stopped, and run again.

Syntax · UserDefaultsRead and write UserDefaults without a property wrapper.
let defaults = UserDefaults.standard
defaults.set("Kim", forKey: "travelerName")
defaults.set(true, forKey: "showsYear")
print(defaults.string(forKey: "travelerName") ?? "none", defaults.bool(forKey: "showsYear"))
print(defaults.string(forKey: "nothingHere") ?? "none", defaults.bool(forKey: "nothingHere"))

set(_:forKey:), and a getter for the type: string(forKey:) is optional, bool(forKey:) gives false for a key that is not there. It printed Kim true and none false.

Syntax · @AppStorageA stored setting that has no value until someone chooses one.
@AppStorage("lastTrip") private var lastTrip: Int?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Make it optional and give no default: it is nil until set. @SceneStorage takes optionals in the same way.

Syntax · UserDefaultsKeep settings in a store of their own — such as the one an app group shares with a widget.
let group = UserDefaults(suiteName: "group.com.example.trips")!
group.set("mint", forKey: "highlight")
print("in the group's store:", group.string(forKey: "highlight") ?? "nothing")
print("in the standard store:", UserDefaults.standard.string(forKey: "highlight") ?? "nothing")

UserDefaults(suiteName:) names another store; @AppStorage("key", store: thatStore) reads and writes it. The two stores are separate: "in the group's store: mint", but "in the standard store: nothing". To share one with a widget, both targets need the same App Group.

Syntax · @AppStorageMake every @AppStorage inside a view use another store — an app group's, say — without naming it in each one.
import SwiftUI

struct HighlightButton: View {
    @AppStorage("highlight") private var highlight = "yellow"
…
HighlightButton()
    .defaultAppStorage(UserDefaults(suiteName: "group.com.example.trips")!)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.defaultAppStorage(_:): every @AppStorage below it that names no store of its own uses this one — "the group's store: mint", while "the standard store: nothing". To share it with a widget, both targets need the App Group.

Syntax · @SceneStorageKeep the selected tab in scene storage as an enum, not a string.
import SwiftUI

enum Screen: String {
    case overview, trips
}
…
@SceneStorage("screen") private var screen = Screen.overview
…
TabView(selection: $screen) {
    Tab("Overview", systemImage: "square.grid.2x2", value: Screen.overview) { Text("The overview") }
    Tab("Trips", systemImage: "list.bullet", value: Screen.trips) { Text("The trips") }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

An enum with String or Int raw values works in @SceneStorage just as in @AppStorage: the selection is typed, so a tab value cannot be misspelt. The picture: Trips chosen, Home, the app stopped and run again.

Syntax · @AppStorageRemember when the app was last opened.
@AppStorage("lastOpened") private var lastOpened: Date?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

An optional Date — @AppStorage has taken dates since iOS 18. The first launch printed "no date stored yet"; after a stop and a run, "a date was stored", and the screen showed the time.

Trick question · @AppStorageCan @AppStorage hold a Codable struct of your own?

No: "no exact matches in call to initializer" It takes a fixed list — Bool, Int, Double, String, URL, Date, Data — and enums with String or Int raw values. For anything bigger, an edge case shows a way through Data, and the right answer is usually SwiftData or a file.

Trick question · @AppStorageA value is set through @AppStorage, and the app is stopped straight after — killed, not sent to the background. Is the value there at the next launch?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Yes: "launched with mint", with the stop straight after the tap. Apple: a UserDefaults object "updates its in-memory version of that information right away, and writes the value to disk asynchronously" — and the stop did not lose it. Compare this lesson's scene storage, which the same stop lost, and SwiftData's autosave on lesson 35.

Trick question · @AppStorage@AppStorage("name") private var name = "nobody", and nothing has been set yet. What does UserDefaults hold for "name"?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Nothing — "UserDefaults holds: nothing". The default lives in the property wrapper, not in UserDefaults, until the first write. An edge case shows what that does to two views with different defaults for the same key.

Trick question · @SceneStorageA tab is chosen, and the app is stopped at once, still in the foreground. Does @SceneStorage bring the tab back?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

No — the app came back on the Overview. Apple: "The system makes no guarantees as to when and how often the data will be persisted." In practice it is saved when the app goes to the background, which a stop in the foreground never reaches.

Trick question · @AppStorageOne view stores an Int under "count"; another reads "count" as a String. After the first sets 5, what does the second show?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

"5". UserDefaults turns a stored number into a string when a string is asked for. No error, no warning — one key, two meanings. Keep each key in one place, with one type.

Trick question · @SceneStorageCan @SceneStorage keep the trip being edited — a Codable struct — so that a half-finished edit is restored?

No: "no exact matches in call to initializer" It takes the same short list as @AppStorage. And Apple's page asks for little: "Ensure that the data you use with SceneStorage is lightweight. Data of a large size, such as model data, should not be stored in SceneStorage". Keep the trip's id there, and the draft with the data.

Trick question · @AppStorageUserDefaults has float(forKey:). Can @AppStorage hold a Float?

No: "no exact matches in call to initializer" @AppStorage has an initializer for Bool, Int, Double, String, URL, Date and Data, their optionals, and enums with Int or String raw values — and nothing else. Use Double.

Edge cases and tricky questions

9 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.

Lesson 36 · question 1 of 9 · two defaults for one key

Three views use @AppStorage("name"), each with a different default. Nothing has been stored yet. What do they show — and after one of them sets the name?

import SwiftUI

struct First: View {
    @AppStorage("name") private var name = "first default"

    var body: some View {
        Text("First: \(name)")
    }
}

struct Second: View {
    @AppStorage("name") private var name = "second default"

    var body: some View {
        Text("Second: \(name)")
    }
}

struct ContentView: View {
    @AppStorage("name") private var name = "third default"

    var body: some View {
        VStack(spacing: 20) {
            First()
            Second()
            Button("Set") { name = "Kim" }
        }
        .onAppear { print("UserDefaults holds:", UserDefaults.standard.object(forKey: "name") ?? "nothing") }
    }
}
Show the answer
Screenshot of this code running
After the tap: one name everywhereScreenshot · iPhone 17, iOS 27.0

Each its own default — until the first write. Before the tap the two views said "First: first default" and "Second: second default", while UserDefaults held nothing:

Console
UserDefaults holds: nothing

A default lives in the property wrapper, not in UserDefaults. Once something is written, every view with that key shows it. That is the second reason to keep keys — and their defaults — in one place: a view that disagrees about the default shows a different setting from the others, with no error.

Where this comes from: AppStorage — Measured on iOS 27.

Lesson 36 · question 2 of 9 · a default registered at launch

The app calls UserDefaults.standard.register(defaults: ["name": "registered default"]) as it starts, and a view declares @AppStorage("name") private var name = "inline default". Which one does the view show?

import SwiftUI

struct ContentView: View {
    @AppStorage("name") private var name = "inline default"

    var body: some View {
        Text("Name: \(name)")
    }
}
Show the answer
Screenshot of this code running
The registered default winsScreenshot · iPhone 17, iOS 27.0

The registered one. UserDefaults has a value for the key — from its registration domain — so the wrapper's own default is never used. Apple: register defaults "shortly after launch", and "The registration domain is volatile, so you must register the set of default values each time your app launches." Registering is one way to keep a default in one place for every view, and for code that reads UserDefaults directly.

Where this comes from: UserDefaults register(defaults:) · AppStorage — Measured on iOS 27.

Lesson 36 · question 3 of 9 · app storage inside an observable class

To keep settings in one object, an @Observable class gets @AppStorage("name") var name = "nobody". Does it compile? And with @ObservationIgnored in front, does a view that shows settings.name update when it changes?

import SwiftUI

@MainActor
@Observable
final class Settings {
    @AppStorage("name") var name = "nobody"
}

struct ContentView: View {
    @State private var settings = Settings()

    var body: some View {
        Text("Name: \(settings.name)")
    }
}
Show the answer

It does not compile. Xcode reports

invalid redeclaration of synthesized property '_name'

No: "invalid redeclaration of synthesized property '_name'" — the two macros both want the property's storage.

import SwiftUI

@MainActor
@Observable
final class Settings {
    @ObservationIgnored @AppStorage("name") var name = "nobody"
}
Screenshot of this code running
After the tap: still “nobody”Screenshot · iPhone 17, iOS 27.0

With @ObservationIgnored it compiles, and the value really is written ("UserDefaults now holds: Kim") — but the view still says nobody. @AppStorage redraws only the view it is declared in; inside a class there is no view to redraw, and Observation was told to ignore the property. Declare @AppStorage in the views that read it, as step 3 does.

Where this comes from: AppStorage · Observable() macro — Measured on iOS 27.

Lesson 36 · question 4 of 9 · written behind its back

A view shows @AppStorage("name"). A button writes the key with UserDefaults.standard.set(_:forKey:) directly, and another removes it. Does the view keep up?

import SwiftUI

struct ContentView: View {
    @AppStorage("name") private var name = "nobody"

    var body: some View {
        VStack(spacing: 20) {
            Text("Name: \(name)")
            Button("Write it directly") { UserDefaults.standard.set("Kim", forKey: "name") }
            Button("Remove it") { UserDefaults.standard.removeObject(forKey: "name") }
        }
    }
}
Show the answer
Screenshot of this code running
After the removal: the default againScreenshot · iPhone 17, iOS 27.0

Yes, both ways. After the direct write the view said "Name: Kim"; after the removal, "Name: nobody" — the wrapper's default, because the key was gone. @AppStorage watches UserDefaults itself, not just its own setter, so code that predates SwiftUI and writes the defaults directly stays in step with the views.

Where this comes from: AppStorage · UserDefaults — Measured on iOS 27.

Lesson 36 · question 5 of 9 · scene storage in the App

@SceneStorage("tab") is declared in the App struct rather than in a view. Does it compile — and does it work?

import SwiftUI

@main
struct ProbeApp: App {
    @SceneStorage("tab") private var tab = "one"

    var body: some Scene {
        WindowGroup {
            Button("Tab: \(tab)") { tab = "two" }
        }
    }
}
Show the answer
Screenshot of this code running
It runsScreenshot · iPhone 17, iOS 27.0

It compiles and runs — and SwiftUI says, twice, that it does not work:

What SwiftUI logged

SceneStorage is only for use with SwiftUI App Lifecycle. This will become a fatal error in a future release.
Accessing a SceneStorage value outside of being installed on a View or outside SwiftUI App Lifecycle. This will always return the default value.

Scene storage belongs to a scene, and only a view knows which scene it is in. That is why this lesson moved the tabs into MainTabs.

Where this comes from: SceneStorage — Measured on iOS 27.

Lesson 36 · question 6 of 9 · a struct, through Data

@AppStorage refuses a Codable struct. Is there a way to keep one there anyway — and should you?

import SwiftUI

struct Place: Codable {
    var city: String
}

struct ContentView: View {
    @AppStorage("place") private var placeData = Data()

    private var place: Place {
        get {
            (try? JSONDecoder().decode(Place.self, from: placeData)) ?? Place(city: "Seattle")
        }
        nonmutating set {
            placeData = (try? JSONEncoder().encode(newValue)) ?? Data()
        }
    }

    var body: some View {
        VStack(spacing: 20) {
            Text("Place: \(place.city)")
            Button("Bergen") { place = Place(city: "Bergen") }
        }
        .onAppear { print("launched with", place.city) }
    }
}
Show the answer
Screenshot of this code running
After a restart: BergenScreenshot · iPhone 17, iOS 27.0

Yes — as Data. Store the JSON in an @AppStorage of type Data, and decode it in a computed property; nonmutating set lets the view's body assign to it, because the real write goes through the wrapper. It survived a restart:

Console
launched with Bergen

Should you? Apple's page for UserDefaults allows it — "To include other types of objects in the defaults database, archive them to a Data object first and store that object instead" — and adds, "Prefer simple types over custom objects whenever possible." For a small value that really is a preference, such as a sort order made of two fields, it is fine. Anything that grows, or that the app treats as data, belongs in SwiftData or a file (lessons 33 and 35).

Where this comes from: AppStorage · UserDefaults — Measured on iOS 27.

Lesson 36 · question 7 of 9 · where the value lives

A view sets @AppStorage("travelerName") to "Kim". Where does that end up on the device, and in what form?

import SwiftUI

struct ContentView: View {
    @AppStorage("travelerName") private var name = ""

    var body: some View {
        VStack(spacing: 20) {
            Text("Name: \(name)")
            Button("Save Kim") { name = "Kim" }
            Button("Read the file") {
                let file = URL.libraryDirectory.appending(path: "Preferences/\(Bundle.main.bundleIdentifier!).plist")
                print("file:", file.pathComponents.suffix(3).joined(separator: "/"))
                if let data = try? Data(contentsOf: file),
                   let contents = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] {
                    print("travelerName in it:", contents["travelerName"] as? String ?? "missing")
                } else {
                    print("no file yet")
                }
            }
        }
    }
}
Show the answer
Screenshot of this code running
The app that read its own fileScreenshot · iPhone 17, iOS 27.0

In a property list in the app's own folder, as it was typed:

Console
file: Library/Preferences/com.example.Probe.plist
travelerName in it: Kim

Any code that can read that file can read the name. Apple's page for UserDefaults: "The defaults system stores information on disk in an unencrypted format. Store personal or sensitive information in the person’s Keychain instead." That is why Trips, in this lesson, keeps the look in UserDefaults and the traveler's name out of it.

The probe opens the file only to show what is in it. An app should not: the same page says "Don’t access the files of the defaults database directly from the file system. Modifying one of the underlying files directly may cause data loss, a delay in changes being available, or an app crash."

Where this comes from: UserDefaults — Measured on iOS 27. The bundle id is the probe app's; a project of your own has its own.

Lesson 36 · question 8 of 9 · a misspelt key

The Settings screen writes @AppStorage("showsYear"); the Overview reads @AppStorage("showYear"). The setting is switched off. What does the Overview do?

import SwiftUI

struct YearLine: View {
    @AppStorage("showYear") private var showsYear = true

    var body: some View {
        Text(showsYear ? "The Overview shows the year" : "The Overview hides the year")
    }
}

struct ContentView: View {
    @AppStorage("showsYear") private var showsYear = true

    var body: some View {
        VStack(spacing: 20) {
            YearLine()
            Button("Hide the year") { showsYear = false }
            Text("Settings says: \(showsYear ? "show" : "hide")")
        }
    }
}
Show the answer
Screenshot of this code running
Two settings where one was meantScreenshot · iPhone 17, iOS 27.0

It goes on showing the year, and nothing complains: these are two different settings, each with its own default. A key is only a string, and the compiler cannot check one. Write each key once, as a constant — Settings.showsYearKey in this lesson's Trips — and use the constant everywhere.

Where this comes from: AppStorage — Measured on iOS 27.

Lesson 36 · question 9 of 9 · one key, two views, one scene

Two views in the same window both declare @SceneStorage("draft"). One sets it. What does the other see?

import SwiftUI

struct DraftLine: View {
    @SceneStorage("draft") private var draft = "empty"

    var body: some View {
        Text("The other view sees: \(draft)")
    }
}

struct ContentView: View {
    @SceneStorage("draft") private var draft = "empty"

    var body: some View {
        VStack(spacing: 20) {
            Text("This view: \(draft)")
            DraftLine()
            Button("Write Bergen") { draft = "Bergen" }
        }
    }
}
Show the answer
Screenshot of this code running
Both say BergenScreenshot · iPhone 17, iOS 27.0

The same value. Apple: "the value is shared with other SceneStorage variables in the same scene." A key is one value per window, wherever it is declared — so two unrelated screens that both pick a short key such as "draft" overwrite each other. Apple's state-restoration sample: "Each scene storage key must be unique, and properly scoped to the area or use within the app."

Where this comes from: SceneStorage · Restoring your app’s state with SwiftUI — Measured on iOS 27.

Further reading

Write the code
Interview problem · Part 10

The take-home, in miniature

“Here is items.json. Decode it, show the items in a list with their remote thumbnails, and handle loading and failure properly. Forty minutes. I care more about your decisions than your pixels.”

{ "items": [
    { "id": 7, "name": "Dungeness Crab", "price_cents": 2450,
      "image_url": "https://example.com/crab.jpg", "tags": ["seafood", "local"] },
    { "id": 8, "name": "Oysters", "price_cents": 1800,
      "image_url": null, "tags": [] }
] }

Where this comes from: DoorDash iOS interview, as reported by candidates (Prepfully) — DoorDash's take-home, as reported: an Xcode project, a JSON sample file and a three-line loader; build the UI, images included

Show the answer
import SwiftUI
struct Menu: Decodable {
    let items: [Item]
}
struct Item: Decodable, Identifiable {
    let id: Int
    let name: String
    let priceCents: Int
    let imageURL: URL?
    let tags: [String]

    enum CodingKeys: String, CodingKey {
        case id, name, tags
        case priceCents = "price_cents"
        case imageURL = "image_url"
    }
}

enum Loadable<Value> {
    case loading
    case loaded(Value)
    case failed(String)
}

@MainActor @Observable
final class MenuModel {
    private(set) var state: Loadable<[Item]> = .loading
    // the seam: bundle file, URLSession, or a test stub
    private let load: () async throws -> Data

    init(load: @escaping () async throws -> Data) {
        self.load = load
    }

    func refresh() async {
        state = .loading
        do {
            let data = try await load()
            state = .loaded(try JSONDecoder().decode(Menu.self, from: data).items)
        } catch {
            state = .failed("Couldn't load the menu.")
        }
    }
}

struct MenuView: View {
    let model: MenuModel

    var body: some View {
        Group {
            switch model.state {
            case .loading:
                ProgressView()
            case .failed(let message):
                ContentUnavailableView(message, systemImage: "wifi.slash")
            case .loaded(let items):
                List(items) { item in
                    HStack(spacing: 12) {
                        AsyncImage(url: item.imageURL) { image in
                            image.resizable().scaledToFill()
                        } placeholder: {
                            Color.gray.opacity(0.2)
                        }
                        .frame(width: 56, height: 56)
                        .clipShape(.rect(cornerRadius: 8))

                        VStack(alignment: .leading) {
                            Text(item.name)
                            Text(Decimal(item.priceCents) / 100, format: .currency(code: "USD"))
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
        }
        .task { await model.refresh() }
    }
}
The sample file, decoded and shown. Its image address is an example, so the placeholder stays — screenshot
The sample file, decoded and shown. Its image address is an example, so the placeholder staysScreenshot · iPhone 17, iOS 27.0

Decisions to say out loud as you go. Explicit CodingKeys, because .convertFromSnakeCase would turn image_url into imageUrl, not imageURL, and the property would quietly fail to decode. An optional URL, so that one null image cannot fail the whole list. One Loadable enum rather than three Booleans that can contradict one another. And the loader passed in as a closure, so the same model reads a bundled file today, URLSession tomorrow, and a stub in tests.

Finish by saying what you would test first: decoding the sample (including the null image), and the model's move from .loading to .loaded and to .failed. None of that needs a view.

What interviewers listen for: That states which cannot happen cannot be represented, that failure is designed rather than ignored, and that you explain your trade-offs instead of typing in silence.