← All lessonsLesson 23 of 57 · Part 7: Lists and navigation
Lesson 23

Bigger screens, and screens on top

Split views and adapting to screen size · Sheets, alerts, and popovers

You will buildOn an iPad, the list of trips beside the trip it opened; a planner that uses the width it has; a trip edited in a sheet with Cancel and Save; and an alert that asks before a trip is deleted.

TimeAbout 110 minutes

WhyTwo subjects in this lesson, and one idea under both: the screen follows the state. A split view shows one column or three depending on the space, from the same code. A sheet or an alert is not presented by a call, as in UIKit; it is shown while a piece of state says so, and goes when the state changes. Interviewers ask about both — "how does this look on an iPad?" and "how do you present an editor for one item?" — and about the bugs that come from getting the state slightly wrong.

Step 1

Open your project

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

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

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

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

The list beside its trip

On an iPhone the list fills the screen and a trip replaces it. An iPad has room for both, side by side, and NavigationSplitView gives you that from one description: a sidebar column and a detail column. The pattern Apple describes is that the sidebar's selection chooses what the detail shows: "the navigation split view coordinates with the List in its first column, so that when people make a selection, the detail view updates accordingly." So the list gets a selection, its rows' values become the selected trip's id, and the detail column shows that trip — or "Choose a trip" when there is none — inside the last lesson's NavigationStack, where the Next links still push. In TripList.swift:

In TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    @Environment(\.highlight) private var highlight    @State private var selection: Trip.ID?    @State private var path: [Trip.ID] = []    var body: some View {        NavigationStack(path: $path) {            List {        NavigationSplitView {            List(selection: $selection) {                ForEach(months, id: \.self) { month in                    let inMonth = trips(in: month)                    Section(month.formatted(.dateTime.month(.wide).year())) {                        ForEach(inMonth) { trip in                            NavigationLink(value: trip.id) {                                TripRow(trip: trip)                            }                            .swipeActions(edge: .leading) {                                Button(trip.isFavorite ? "Unfavorite" : "Favorite",                                       systemImage: trip.isFavorite ? "star.slash" : "star") {                                    store.toggleFavorite(trip)                                }                                .tint(highlight)                            }                        }                        .onDelete { offsets in                            store.delete(offsets.map { inMonth[$0] })                        }                    }                }            }            .navigationTitle("Trips")            .navigationDestination(for: Trip.ID.self) { id in                TripDetail(id: id, path: $path)            }            .toolbar {                Button("Soonest", systemImage: "calendar") {                    if let soonest = store.trips.min(by: { $0.start < $1.start }) {                        path = [soonest.id]                    selection = store.trips.min(by: { $0.start < $1.start })?.id                }            }        } detail: {            NavigationStack(path: $path) {                Group {                    if let selection {                        TripDetail(id: selection, selection: $selection)                    } else {                        ContentUnavailableView("Choose a trip", systemImage: "airplane",                                               description: Text("Its details will appear here."))                    }                }                .navigationDestination(for: Trip.ID.self) { id in                    TripDetail(id: id, selection: $selection)                }            }        }        .onChange(of: path) {            print("path:", path)        }    }    /// The first day of every month in which a trip starts, earliest first.    private var months: [Date] {        Set(store.trips.map(\.start.startOfMonth)).sorted()    }    /// The trips that start in that month, earliest first.    private func trips(in month: Date) -> [Trip] {        store.trips.filter { $0.start.startOfMonth == month }.sorted { $0.start < $1.start }    }}
struct TripList: View {
    @Environment(TripStore.self) private var store
    @Environment(\.highlight) private var highlight
    @State private var selection: Trip.ID?
    @State private var path: [Trip.ID] = []

    var body: some View {
        NavigationSplitView {
            List(selection: $selection) {
                ForEach(months, id: \.self) { month in
                    let inMonth = trips(in: month)
                    Section(month.formatted(.dateTime.month(.wide).year())) {
                        ForEach(inMonth) { trip in
                            NavigationLink(value: trip.id) {
                                TripRow(trip: trip)
                            }
                            .swipeActions(edge: .leading) {
                                Button(trip.isFavorite ? "Unfavorite" : "Favorite",
                                       systemImage: trip.isFavorite ? "star.slash" : "star") {
                                    store.toggleFavorite(trip)
                                }
                                .tint(highlight)
                            }
                        }
                        .onDelete { offsets in
                            store.delete(offsets.map { inMonth[$0] })
                        }
                    }
                }
            }
            .navigationTitle("Trips")
            .toolbar {
                Button("Soonest", systemImage: "calendar") {
                    selection = store.trips.min(by: { $0.start < $1.start })?.id
                }
            }
        } detail: {
            NavigationStack(path: $path) {
                Group {
                    if let selection {
                        TripDetail(id: selection, selection: $selection)
                    } else {
                        ContentUnavailableView("Choose a trip", systemImage: "airplane",
                                               description: Text("Its details will appear here."))
                    }
                }
                .navigationDestination(for: Trip.ID.self) { id in
                    TripDetail(id: id, selection: $selection)
                }
            }
        }
        .onChange(of: path) {
            print("path:", path)
        }
    }

    /// The first day of every month in which a trip starts, earliest first.
    private var months: [Date] {
        Set(store.trips.map(\.start.startOfMonth)).sorted()
    }

    /// The trips that start in that month, earliest first.
    private func trips(in month: Date) -> [Trip] {
        store.trips.filter { $0.start.startOfMonth == month }.sorted { $0.start < $1.start }
    }
}

The detail now closes by clearing the selection rather than the path, so it takes a binding to that. In TripDetail.swift, path becomes selection, and All trips sets it to nil:

In TripDetail.swift, replace the TripDetail struct with this
struct TripDetail: View {    let id: Trip.ID    @Binding var path: [Trip.ID]    @Binding var selection: Trip.ID?    @Environment(TripStore.self) private var store    var body: some View {        @Bindable var store = store        if let index = store.trips.firstIndex(where: { $0.id == id }) {            ScrollView {                VStack(spacing: 16) {                    TripCard(trip: $store.trips[index])                    if let next = next(after: store.trips[index]) {                        NavigationLink("Next: \(next.city)", value: next.id)                    }                }            }            .navigationTitle(store.trips[index].city)            .toolbar {                Button("All trips", systemImage: "list.bullet") {                    path.removeAll()                    selection = nil                }            }        } else {            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")        }    }    /// The trip that starts soonest after this one, if there is one.    private func next(after trip: Trip) -> Trip? {        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }    }}
struct TripDetail: View {
    let id: Trip.ID
    @Binding var selection: Trip.ID?
    @Environment(TripStore.self) private var store

    var body: some View {
        @Bindable var store = store
        if let index = store.trips.firstIndex(where: { $0.id == id }) {
            ScrollView {
                VStack(spacing: 16) {
                    TripCard(trip: $store.trips[index])
                    if let next = next(after: store.trips[index]) {
                        NavigationLink("Next: \(next.city)", value: next.id)
                    }
                }
            }
            .navigationTitle(store.trips[index].city)
            .toolbar {
                Button("All trips", systemImage: "list.bullet") {
                    selection = nil
                }
            }
        } else {
            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")
        }
    }

    /// The trip that starts soonest after this one, if there is one.
    private func next(after trip: Trip) -> Trip? {
        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }
    }
}
In TripDetail.swift, replace the preview with this
#Preview {    NavigationStack {        TripDetail(id: 1, path: .constant([1]))        TripDetail(id: 1, selection: .constant(1))    }    .environment(TripStore())}
#Preview {
    NavigationStack {
        TripDetail(id: 1, selection: .constant(1))
    }
    .environment(TripStore())
}

Run the app on the iPhone and tap Trips:

On an iPhone: one column, as before — screenshot
On an iPhone: one column, as beforeScreenshot · iPhone 17, iOS 27.0

Seattle, Next and All trips behave as they did in the last lesson. (All trips closes two screens at once, and Xcode's console may show the last lesson's line from SwiftUI about its own glass effect. It comes and goes from one run to the next, and it is SwiftUI's, not yours.)

On the iPhone nothing looks different: "with narrow sizes like on iPhone … the navigation split view collapses all of its columns into a stack". To see the other half, run it on an iPad. In the run destination menu in Xcode's toolbar, choose an iPad simulator — iPad Air 11-inch (M4) — and run (⌘R):

Tap Trips:

On an iPad: the list, with Seattle beside it — screenshot
On an iPad: the list, with Seattle beside itScreenshot · iPad Air 11-inch (M4), iOS 27.0
Before a trip is chosen: "Choose a trip" — screenshot
Before a trip is chosen: "Choose a trip"Screenshot · iPad Air 11-inch (M4), iOS 27.0

Tap Seattle — the picture at the top of this step: the list stays, and Seattle opens beside it. Then Next: Vancouver:

Next pushes within the detail column; the list stays where it was — screenshot
Next pushes within the detail column; the list stays where it wasScreenshot · iPad Air 11-inch (M4), iOS 27.0

Now choose another trip in the list, San Pedro de Atacama, and watch the console:

Console
path: []

The path emptied by itself. Choosing a new trip in the sidebar replaced what the detail column's stack showed — Apple: a link in an earlier column "sets the view that the stack displays over its root view" — so Vancouver's page did not stay piled on top of Atacama. You wrote no code for that.

The same TripList and TripDetail as on the iPhone, and the system chose the layout. Why not the last lesson's value links all the way? Because a link looks for its destination in its own column: a link in the sidebar cannot see a destination declared in the detail column, and a link in the detail column cannot see one declared on the sidebar. SwiftUI logs exactly that when you try — one of this lesson's edge cases runs both. Selection for the sidebar, a stack for the detail, is the arrangement that works in both directions.

Step 3

A layout that uses the width

Your own views can make the same choice. SwiftUI describes the space with two size classes, horizontal and vertical, each compact or regular, in the environment. An iPhone held upright is compact wide; an iPad is regular wide. On a regular width the planner's City and Country fields fit side by side. In TripPlanner.swift, read the size class and choose the container with lesson 11's AnyLayout:

In TripPlanner.swift, replace the TripPlanner struct with this
struct TripPlanner: View {    @Environment(TripStore.self) private var store    @State private var isOpen = false    @State private var draft = TripDraft()    @Environment(\.horizontalSizeClass) private var sizeClass    var body: some View {        VStack(spacing: 12) {            VStack(alignment: .leading, spacing: 12) {                Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") {                    isOpen.toggle()                }                .buttonStyle(.bordered)                if isOpen {                    TextField("City", text: $draft.city)                    TextField("Country", text: $draft.country)                    let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading))                    fields {                        TextField("City", text: $draft.city)                        TextField("Country", text: $draft.country)                    }                    Stepper(value: $draft.nights, in: 1...60) {                        Text("^[\(draft.nights) night](inflect: true)")                    }                    Stepper(value: $draft.price, in: 0...20_000, step: 250) {                        Text(draft.price, format: .currency(code: "USD"))                    }                    HStack {                        PicturePicker(selection: $draft.imageName)                        Spacer()                        FavoriteButton(isFavorite: $draft.isFavorite)                    }                    Button("Add to my trips") {                        store.add(draft)                        draft = TripDraft()                        isOpen = false                    }                    .buttonStyle(.borderedProminent)                    .disabled(draft.city.isEmpty)                }            }            .textFieldStyle(.roundedBorder)            .frame(maxWidth: .infinity, alignment: .leading)            .padding(.horizontal)            if isOpen {                TripCard(trip: Binding(                    get: { draft.trip(id: 0) },                    set: { draft.isFavorite = $0.isFavorite }                ))            }        }    }}
struct TripPlanner: View {
    @Environment(TripStore.self) private var store
    @State private var isOpen = false
    @State private var draft = TripDraft()
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        VStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 12) {
                Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") {
                    isOpen.toggle()
                }
                .buttonStyle(.bordered)
                if isOpen {
                    let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading))
                    fields {
                        TextField("City", text: $draft.city)
                        TextField("Country", text: $draft.country)
                    }
                    Stepper(value: $draft.nights, in: 1...60) {
                        Text("^[\(draft.nights) night](inflect: true)")
                    }
                    Stepper(value: $draft.price, in: 0...20_000, step: 250) {
                        Text(draft.price, format: .currency(code: "USD"))
                    }
                    HStack {
                        PicturePicker(selection: $draft.imageName)
                        Spacer()
                        FavoriteButton(isFavorite: $draft.isFavorite)
                    }
                    Button("Add to my trips") {
                        store.add(draft)
                        draft = TripDraft()
                        isOpen = false
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(draft.city.isEmpty)
                }
            }
            .textFieldStyle(.roundedBorder)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal)
            if isOpen {
                TripCard(trip: Binding(
                    get: { draft.trip(id: 0) },
                    set: { draft.isFavorite = $0.isFavorite }
                ))
            }
        }
    }
}

Run it on the iPad, and tap Plan a trip on the Overview:

The planner on an iPad: City and Country on one line — screenshot
The planner on an iPad: City and Country on one lineScreenshot · iPad Air 11-inch (M4), iOS 27.0

On the iPhone the fields stay one above the other. Read the size class, not the kind of device: Apple's documentation warns to "be prepared to handle size class changes while your app runs" — an iPad app can be resized, and since iOS 27 an iPhone app can be shown at many sizes too (lesson 23's notes). A layout keyed to the space is right on all of them; one keyed to "is this an iPad?" is right on few.

Step 4

Editing in a sheet

Now the second lesson. A trip can be changed from its screen: tap Edit, and an editor slides up over it. The editor is a view of its own, holding a draft — a copy of the trip — so that Cancel can throw the changes away and only Save hands them back. Make a SwiftUI View named TripEditor:

New fileIn TripEditor.swift, replace the TripEditor struct with this
struct TripEditor: View {    @State private var draft: Trip    let onSave: (Trip) -> Void    @Environment(\.dismiss) private var dismiss    init(trip: Trip, onSave: @escaping (Trip) -> Void) {        _draft = State(initialValue: trip)        self.onSave = onSave    }    var body: some View {        Text("Hello, World!")        NavigationStack {            Form {                TextField("City", text: $draft.city)                TextField("Country", text: $draft.country)                Stepper(value: $draft.nights, in: 1...60) {                    Text("^[\(draft.nights) night](inflect: true)")                }            }            .navigationTitle("Edit trip")            .navigationBarTitleDisplayMode(.inline)            .toolbar {                ToolbarItem(placement: .cancellationAction) {                    Button("Cancel") { dismiss() }                }                ToolbarItem(placement: .confirmationAction) {                    Button("Save") {                        onSave(draft)                        dismiss()                    }                }            }        }    }}
struct TripEditor: View {
    @State private var draft: Trip
    let onSave: (Trip) -> Void
    @Environment(\.dismiss) private var dismiss

    init(trip: Trip, onSave: @escaping (Trip) -> Void) {
        _draft = State(initialValue: trip)
        self.onSave = onSave
    }

    var body: some View {
        NavigationStack {
            Form {
                TextField("City", text: $draft.city)
                TextField("Country", text: $draft.country)
                Stepper(value: $draft.nights, in: 1...60) {
                    Text("^[\(draft.nights) night](inflect: true)")
                }
            }
            .navigationTitle("Edit trip")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        onSave(draft)
                        dismiss()
                    }
                }
            }
        }
    }
}
In TripEditor.swift, replace the preview with this
#Preview {    TripEditor()    TripEditor(trip: .seattle) { _ in }}
#Preview {
    TripEditor(trip: .seattle) { _ in }
}

Three things from earlier days: State(initialValue:) for a draft that starts from the trip and is the editor's own from then on (lesson 17 — here the "trap" is exactly what you want); @Environment(\.dismiss) to close itself (lesson 19); and toolbar placements that say what a button is — cancel, confirm — so that the system puts each where it belongs. The Form is lesson 30's, used here for its look. The store needs a way to replace a trip. In TripStore.swift:

In TripStore.swift, replace the TripStore class with this
/// The trips, for the whole app. `@MainActor`: the views read it, and the views/// are drawn on the main thread.@MainActor@Observablefinal class TripStore {    var trips = Trip.samples    /// Adds the trip a draft describes, with an id no other trip has.    func add(_ draft: TripDraft) {        let id = (trips.map(\.id).max() ?? 0) + 1        trips.append(draft.trip(id: id))    }    /// Removes these trips, whatever order they are in.    func delete(_ doomed: [Trip]) {        let ids = Set(doomed.map(\.id))        trips.removeAll { ids.contains($0.id) }    }    /// Makes a trip a favorite, or not.    func toggleFavorite(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index].isFavorite.toggle()        }    }    /// Replaces the trip with the same id.    func update(_ trip: Trip) {        if let index = trips.firstIndex(where: { $0.id == trip.id }) {            trips[index] = trip        }    }    func save() {        print("Saving \(trips.count) trips")       // a real save comes in Part 10    }}
/// The trips, for the whole app. `@MainActor`: the views read it, and the views
/// are drawn on the main thread.
@MainActor
@Observable
final class TripStore {
    var trips = Trip.samples

    /// Adds the trip a draft describes, with an id no other trip has.
    func add(_ draft: TripDraft) {
        let id = (trips.map(\.id).max() ?? 0) + 1
        trips.append(draft.trip(id: id))
    }

    /// Removes these trips, whatever order they are in.
    func delete(_ doomed: [Trip]) {
        let ids = Set(doomed.map(\.id))
        trips.removeAll { ids.contains($0.id) }
    }

    /// Makes a trip a favorite, or not.
    func toggleFavorite(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index].isFavorite.toggle()
        }
    }

    /// Replaces the trip with the same id.
    func update(_ trip: Trip) {
        if let index = trips.firstIndex(where: { $0.id == trip.id }) {
            trips[index] = trip
        }
    }

    func save() {
        print("Saving \(trips.count) trips")       // a real save comes in Part 10
    }
}

And the detail shows the editor. The sheet is presented with .sheet(item:): it appears while editing holds a trip, is handed that trip, and goes when editing is nil again — which SwiftUI sets when the sheet closes. .presentationDetents([.medium, .large]) lets it rest at half height. In TripDetail.swift:

In TripDetail.swift, replace the TripDetail struct with this
struct TripDetail: View {    let id: Trip.ID    @Binding var selection: Trip.ID?    @Environment(TripStore.self) private var store    @State private var editing: Trip?    var body: some View {        @Bindable var store = store        if let index = store.trips.firstIndex(where: { $0.id == id }) {            ScrollView {                VStack(spacing: 16) {                    TripCard(trip: $store.trips[index])                    if let next = next(after: store.trips[index]) {                        NavigationLink("Next: \(next.city)", value: next.id)                    }                }            }            .navigationTitle(store.trips[index].city)            .toolbar {                Button("All trips", systemImage: "list.bullet") {                    selection = nil                }                Button("Edit") {                    editing = store.trips[index]                }            }            .sheet(item: $editing) { trip in                TripEditor(trip: trip) { edited in                    store.update(edited)                }                .presentationDetents([.medium, .large])            }        } else {            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")        }    }    /// The trip that starts soonest after this one, if there is one.    private func next(after trip: Trip) -> Trip? {        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }    }}
struct TripDetail: View {
    let id: Trip.ID
    @Binding var selection: Trip.ID?
    @Environment(TripStore.self) private var store
    @State private var editing: Trip?

    var body: some View {
        @Bindable var store = store
        if let index = store.trips.firstIndex(where: { $0.id == id }) {
            ScrollView {
                VStack(spacing: 16) {
                    TripCard(trip: $store.trips[index])
                    if let next = next(after: store.trips[index]) {
                        NavigationLink("Next: \(next.city)", value: next.id)
                    }
                }
            }
            .navigationTitle(store.trips[index].city)
            .toolbar {
                Button("All trips", systemImage: "list.bullet") {
                    selection = nil
                }
                Button("Edit") {
                    editing = store.trips[index]
                }
            }
            .sheet(item: $editing) { trip in
                TripEditor(trip: trip) { edited in
                    store.update(edited)
                }
                .presentationDetents([.medium, .large])
            }
        } else {
            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")
        }
    }

    /// The trip that starts soonest after this one, if there is one.
    private func next(after trip: Trip) -> Trip? {
        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }
    }
}

Run the app on the iPhone. Open Seattle and tap Edit:

The editor, at half height over Seattle — screenshot
The editor, at half height over SeattleScreenshot · iPhone 17, iOS 27.0

Tap + once, and Save:

Saved: 6 nights — screenshot
Saved: 6 nightsScreenshot · iPhone 17, iOS 27.0

The sheet closed, and Seattle says 6 nights. update changed the trip in the store, so every view that reads Seattle's nights follows, the Overview's card included. Cancel calls only dismiss(), so the draft goes with the sheet and the store never hears of it.

Step 5

Are you sure?

Deleting a trip from its screen deserves a question first. An alert is presented the same way as a sheet: it shows while a Boolean is true. Its buttons have roles — .destructive draws Delete in red, .cancel marks the safe way out — and either one closes the alert. In TripDetail.swift:

In TripDetail.swift, replace the TripDetail struct with this
struct TripDetail: View {    let id: Trip.ID    @Binding var selection: Trip.ID?    @Environment(TripStore.self) private var store    @State private var editing: Trip?    @State private var isConfirmingDelete = false    var body: some View {        @Bindable var store = store        if let index = store.trips.firstIndex(where: { $0.id == id }) {            ScrollView {                VStack(spacing: 16) {                    TripCard(trip: $store.trips[index])                    if let next = next(after: store.trips[index]) {                        NavigationLink("Next: \(next.city)", value: next.id)                    }                }            }            .navigationTitle(store.trips[index].city)            .toolbar {                Button("All trips", systemImage: "list.bullet") {                    selection = nil                }                Button("Edit") {                    editing = store.trips[index]                }                Button("Delete trip", systemImage: "trash", role: .destructive) {                    isConfirmingDelete = true                }            }            .alert("Delete \(store.trips[index].city)?", isPresented: $isConfirmingDelete) {                Button("Delete", role: .destructive) {                    store.delete([store.trips[index]])                }                Button("Cancel", role: .cancel) { }            } message: {                Text("This cannot be undone.")            }            .sheet(item: $editing) { trip in                TripEditor(trip: trip) { edited in                    store.update(edited)                }                .presentationDetents([.medium, .large])            }        } else {            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")        }    }    /// The trip that starts soonest after this one, if there is one.    private func next(after trip: Trip) -> Trip? {        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }    }}
struct TripDetail: View {
    let id: Trip.ID
    @Binding var selection: Trip.ID?
    @Environment(TripStore.self) private var store
    @State private var editing: Trip?
    @State private var isConfirmingDelete = false

    var body: some View {
        @Bindable var store = store
        if let index = store.trips.firstIndex(where: { $0.id == id }) {
            ScrollView {
                VStack(spacing: 16) {
                    TripCard(trip: $store.trips[index])
                    if let next = next(after: store.trips[index]) {
                        NavigationLink("Next: \(next.city)", value: next.id)
                    }
                }
            }
            .navigationTitle(store.trips[index].city)
            .toolbar {
                Button("All trips", systemImage: "list.bullet") {
                    selection = nil
                }
                Button("Edit") {
                    editing = store.trips[index]
                }
                Button("Delete trip", systemImage: "trash", role: .destructive) {
                    isConfirmingDelete = true
                }
            }
            .alert("Delete \(store.trips[index].city)?", isPresented: $isConfirmingDelete) {
                Button("Delete", role: .destructive) {
                    store.delete([store.trips[index]])
                }
                Button("Cancel", role: .cancel) { }
            } message: {
                Text("This cannot be undone.")
            }
            .sheet(item: $editing) { trip in
                TripEditor(trip: trip) { edited in
                    store.update(edited)
                }
                .presentationDetents([.medium, .large])
            }
        } else {
            ContentUnavailableView("This trip is gone", systemImage: "airplane.departure")
        }
    }

    /// The trip that starts soonest after this one, if there is one.
    private func next(after trip: Trip) -> Trip? {
        store.trips.filter { $0.start > trip.start }.min { $0.start < $1.start }
    }
}

Run the app, open Seattle, and tap the trash can:

The question, before anything is deleted — screenshot
The question, before anything is deletedScreenshot · iPhone 17, iOS 27.0

Tap Delete in the alert:

Deleted: the screen says the trip is gone — screenshot
Deleted: the screen says the trip is goneScreenshot · iPhone 17, iOS 27.0

The screen did not have to be closed by hand. TripDetail looks its trip up by id, found none, and showed "This trip is gone" — lesson 22's design paying for itself. A tap on the Trips tab returns to the list, where Seattle is no longer there.

What you built

Next, Part 7 ends with toolbars, menus and search: the Trips tab gets a search field that filters as you type — and the part's interview problem, a deep link into the navigation you built in this lesson and the last.

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

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

Syntax · Split viewsA list in one column, and the item chosen in it beside it — on an iPad.
NavigationSplitView {
    List(cities, id: \.self, selection: $selection) { city in
        Text(city)
    }
    .navigationTitle("Trips")
} detail: {
    Text(selection.map { "All about \($0)" } ?? "Choose a trip")
}
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

NavigationSplitView { sidebar } detail: { … }, with the sidebar's List bound to a selection that the detail reads. The picture is an iPad after a tap on Vancouver; on an iPhone the same code is one stack.

Syntax · Split viewsThree columns: countries, the cities of the chosen country, and the chosen city.
NavigationSplitView {
    …
} content: {
    …
} detail: {
    …
}
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

NavigationSplitView { } content: { } detail: { }. The sidebar's selection drives the middle column, and the middle column's drives the detail. The picture is an iPad held sideways, after Canada, then Vancouver — held upright, it hides the first column (one of this lesson's edge cases).

Syntax · SizeFind out whether the screen is wide or narrow, the way SwiftUI describes it.
@Environment(\.horizontalSizeClass) private var sizeClass
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

@Environment(\.horizontalSizeClass): .compact or .regular. An iPhone held upright is compact wide — the picture; an iPad is regular. verticalSizeClass is the other direction.

Syntax · SizeShow the first of several layouts that fits the space — wide if it can, narrow if it must.
ViewThatFits {
    Label("Seattle, June 12 to June 17, 5 nights", systemImage: "airplane")
    Label("Seattle, 5 nights", systemImage: "airplane")
    Image(systemName: "airplane")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

ViewThatFits tries its children in order and shows the first that fits — so put the most generous first. No size class needed; it measures the space it is actually given.

Syntax · SheetsShow a sheet while a Boolean is true.
.sheet(isPresented: $isShown) {
    Text("The sheet")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.sheet(isPresented:). Set the Boolean to show it; SwiftUI sets it back to false when the sheet goes, however it goes.

Syntax · SheetsShow a sheet for one particular item, and hand the item to it.
@State private var editing: City?
…
.sheet(item: $editing) { city in
    Text("Editing \(city.name)")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.sheet(item:) with an optional Identifiable value: the sheet shows while it is not nil, and its closure is handed the value itself. Use it whenever a sheet is about one thing.

Syntax · SheetsA sheet that can rest at half height.
Text("A medium sheet")
    .presentationDetents([.medium, .large])
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.presentationDetents([.medium, .large]), on the sheet's content. Apple: "By default, sheets support the large detent." The picture is the sheet as it first appeared, at half height; dragging its top edge moves it between the two.

Syntax · AlertsAsk before deleting, with a red Delete and a Cancel.
.alert("Delete Seattle?", isPresented: $isAsking) {
    Button("Delete", role: .destructive) { }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("This cannot be undone.")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.alert(_:isPresented:actions:message:). Roles do the styling: .destructive is red, .cancel is the safe way out. Any button closes the alert; there is nothing to dismiss by hand.

Syntax · SheetsA screen that covers everything, with no swipe to close it.
.fullScreenCover(isPresented: $isShown) {
    VStack(spacing: 20) {
        Text("A full-screen cover")
        Button("Done") { isShown = false }
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.fullScreenCover(isPresented:). It takes the whole screen and cannot be dragged away, so it must offer its own way out — here, Done.

Syntax · SheetsA sheet that cannot be swiped away until the user decides.
.interactiveDismissDisabled()
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.interactiveDismissDisabled() on the sheet's content: dragging it down does nothing (the picture is after a drag). Offer a button that closes it.

Trick question · SheetsA row's button sets chosen = city and then isShown = true; the .sheet(isPresented:) shows Editing \(chosen). The user taps Edit Vancouver. What does the sheet say?
Button("Edit \(city)") {
    chosen = city
    isShown = true
}
…
.sheet(isPresented: $isShown) {
    Text("Editing \(chosen)")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

"Editing nothing." The sheet's closure was made the last time body ran, when chosen was still "nothing" — and body itself never reads chosen, so setting it did not make body run again. Use .sheet(item:): the sheet is handed the item itself.

Trick question · SheetsA sheet holds a NavigationStack. On its second screen, a Close button calls dismiss(). What closes?
@Environment(\.dismiss) private var dismiss
…
Button("Close") { dismiss() }
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Only the second screen — the sheet stays up, on its first screen (the picture). dismiss undoes the presentation nearest to where it is read, and for a pushed screen that is the push. To close the whole sheet from deeper in, hand the screen the sheet's own Boolean.

Trick question · SheetsA sheet with .presentationDetents([.medium, .large]) is shown on an iPhone held sideways. How tall is it?
Text("A sheet, on an iPhone held sideways")
    .presentationDetents([.medium, .large])
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The whole screen. Apple: "In vertically compact environments, such as iPhone in landscape orientation, a sheet presentation automatically adapts to appear as a full-screen cover." Give such a sheet a button that closes it.

Trick question · Sheets.popover(isPresented:) on an iPhone. What appears?
.popover(isPresented: $isShown) {
    Text("A popover")
        .padding()
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

A sheet. Apple: "On iPhone, popovers adapt into sheets." To keep the bubble, add .presentationCompactAdaptation(.popover) to the popover's content — one of this lesson's edge cases shows both.

Trick question · AlertsA confirmationDialog with one destructive button, on an iPhone with iOS 27. Where does it appear, and where is its Cancel button?
.confirmationDialog("Delete Seattle?", isPresented: $isAsking, titleVisibility: .visible) {
    Button("Delete", role: .destructive) { }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

In a bubble beside the button that opened it, with no Cancel button — a tap anywhere outside it is the no. Apple's reference describes that bubble for regular size classes, an iPad; on iOS 27 an iPhone shows it too. Write the buttons by role and let the system choose the look.

Trick question · SizeAn iPhone 17 is held sideways, so it is wider than it is tall. What are its horizontal and vertical size classes?
@Environment(\.horizontalSizeClass) private var horizontal
@Environment(\.verticalSizeClass) private var vertical
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Compact and compact. Sideways, this iPhone is compact in both directions — which is why a split view stays one column on it and a sheet fills the screen. A size class describes the kind of space, not its number of points.

Trick question · Split viewsOn an iPad: a sidebar row NavigationLink(city, value: city), and the matching navigationDestination inside the detail column's NavigationStack. A row is tapped. What happens?
List(cities, id: \.self) { city in
    NavigationLink(city, value: city)
}
…
NavigationStack {
    ContentUnavailableView("Choose a trip", systemImage: "airplane")
        .navigationDestination(for: String.self) { city in
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

Nothing opens. SwiftUI logs that no destination is "visible from the location of the link": the row is in the sidebar column, the destination in the detail column. Give the sidebar's List a selection and let the detail read it, as Trips does.

Trick question · SizeViewThatFits { Text("Short"); Text("A much longer label that would also have fitted") }. Which one is shown?
ViewThatFits {
    Text("Short")
    Text("A much longer label that would also have fitted")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

"Short". ViewThatFits shows the first child that fits — not the biggest, not the best. So list the children from most to least generous. (The second one in the picture skipped a first child that was too long.)

Edge cases and tricky questions

20 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.

Split views and adapting to screen size

Lesson 23 · question 1 of 10 · a link in another column

The last lesson's pattern — value links, a destination, a NavigationStack — moved into a NavigationSplitView on an iPad. Where can the destination go so that both a sidebar row and a Next link inside the detail work?

import SwiftUI

struct Detail: View {
    let city: String

    var body: some View {
        VStack(spacing: 20) {
            Text("Detail of \(city)")
            NavigationLink("Next: Vancouver", value: "Vancouver")
        }
        .navigationTitle(city)
    }
}

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]

    var body: some View {
        NavigationSplitView {
            List(cities, id: \.self) { city in
                NavigationLink(city, value: city)
            }
            .navigationTitle("Trips")
        } detail: {
            NavigationStack {
                ContentUnavailableView("Choose a trip", systemImage: "airplane")
                    .navigationDestination(for: String.self) { city in
                        Detail(city: city)
                    }
            }
        }
    }
}
Show the answer
Screenshot of this code running
Destination in the detail column: a tap on Seattle opens nothingScreenshot · iPad Air 11-inch (M4), iOS 27.0

Neither of the two obvious places works for both. With the destination inside the detail column's stack — the code in the question — a tap on a sidebar row does nothing, and SwiftUI says why:

SwiftUI wrote this to the log while the app ran

A NavigationLink is presenting a value of type “String” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated.

Note: Links search for destinations in any surrounding NavigationStack, then within the same column of a NavigationSplitView.

The message's last line is the rule: links search "in any surrounding NavigationStack, then within the same column of a NavigationSplitView." The row is in the sidebar column, and the destination is in the detail column. (An iPhone, where the same code collapses into one stack, logged the same message.) Move the destination onto the sidebar's list, and the rows work — but now the Next link inside the detail cannot see it, and logs the same message when tapped:

List(cities, id: \.self) { city in
    NavigationLink(city, value: city)
}
.navigationTitle("Trips")
.navigationDestination(for: String.self) { city in
    Detail(city: city)
}
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

The arrangement that works both ways is the one Apple's page for NavigationSplitView begins with: the sidebar's List has a selection, so that "when people make a selection, the detail view updates accordingly", and the detail column's own NavigationStack handles what is pushed inside it:

List(cities, id: \.self, selection: $selection) { city in
    NavigationLink(city, value: city)
}
…
NavigationStack(path: $path) {
    Group {
        if let selection {
            Detail(city: selection)
        } else {
            ContentUnavailableView("Choose a trip", systemImage: "airplane")
        }
    }
    .navigationDestination(for: String.self) { city in
        Detail(city: city)
    }
}
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

Seattle opened, Next pushed Vancouver beside the list, and nothing was logged. That is this lesson's Trips.

Where this comes from: NavigationSplitView · navigationDestination(for:destination:) · WWDC22: The SwiftUI cookbook for navigation — The log line is SwiftUI's own, read from the simulator. Apple, NavigationSplitView: "the link must present a data type for which the stack has a corresponding navigationDestination(for:destination:) modifier."

Lesson 23 · question 2 of 10 · what a new selection does to the detail stack

On an iPad, Seattle is chosen and its Next link has pushed Vancouver in the detail column. Now Atacama is chosen in the sidebar. Is Vancouver still on the detail stack? (No code of yours resets the path.)

import SwiftUI

struct Detail: View {
    let city: String

    var body: some View {
        VStack(spacing: 20) {
            Text("Detail of \(city)")
            NavigationLink("Next: Vancouver", value: "Vancouver")
        }
        .navigationTitle(city)
    }
}

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]

    @State private var selection: String?
    @State private var path: [String] = []

    var body: some View {
        NavigationSplitView {
            List(cities, id: \.self, selection: $selection) { city in
                NavigationLink(city, value: city)
            }
            .navigationTitle("Trips")
        } detail: {
            NavigationStack(path: $path) {
                Group {
                    if let selection {
                        Detail(city: selection)
                    } else {
                        ContentUnavailableView("Choose a trip", systemImage: "airplane")
                    }
                }
                .navigationDestination(for: String.self) { city in
                    Detail(city: city)
                }
            }
        }
        .onChange(of: path) { print("path:", path) }
        .onChange(of: selection) { print("selection:", selection ?? "nil") }
    }
}
Show the answer
Screenshot of this code running
After choosing Atacama: its own page, with nothing on topScreenshot · iPad Air 11-inch (M4), iOS 27.0

No — SwiftUI emptied the path itself. The console, for the push:

Console
path: ["Vancouver"]

and then for the new choice in the sidebar:

Console
path: []
selection: Atacama

Apple's page describes it: a link in an earlier column "sets the view that the stack displays over its root view" — the stack starts again from the new choice, instead of leaving the last trip's pages piled on top of it.

So code that also clears the path when the selection changes is harmless, and redundant. What still needs your code is the other direction: closing the detail on an iPhone means clearing the selection, as this lesson's All trips does.

Where this comes from: NavigationSplitView — Apple, NavigationSplitView: "Tapping or clicking a NavigationLink that appears in an earlier column sets the view that the stack displays over its root view."

Lesson 23 · question 3 of 10 · size classes, measured

What are the horizontal and vertical size classes of an iPhone 17 held upright, an iPhone 17 held sideways, and an iPad Air?

import SwiftUI

struct ContentView: View {
    @Environment(\.horizontalSizeClass) private var horizontal
    @Environment(\.verticalSizeClass) private var vertical

    var body: some View {
        VStack(spacing: 12) {
            Text("Horizontal: \(horizontal == .regular ? "regular" : "compact")")
            Text("Vertical: \(vertical == .regular ? "regular" : "compact")")
        }
    }
}
Show the answer
Screenshot of this code running
iPhone 17, upright: compact wide, regular tallScreenshot · iPhone 17, iOS 27.0
HorizontalVertical
iPhone 17, uprightcompactregular
iPhone 17, sidewayscompactcompact
iPad Air 11-inch, uprightregularregular
Screenshot of this code running
iPhone 17, sideways: compact both waysScreenshot · iPhone 17, iOS 27.0
Screenshot of this code running
iPad Air: regular both waysScreenshot · iPad Air 11-inch (M4), iOS 27.0

Two surprises. Sideways, this iPhone is still compact wide, though it is wider than it is tall. And it is compact tall, which is what makes a sheet fill the screen (lesson 23). Apple lists what sets the class: "The current device type. The orientation of the device. The appearance of Slide Over and Split View on iPad." — so an iPad app sharing the screen can be compact wide as well.

That is the argument for asking the size class and not the device: the answer can change while the app runs, on one device, and the size class changes with it.

Where this comes from: UserInterfaceSizeClass · horizontalSizeClass — Apple, UserInterfaceSizeClass: "be prepared to handle size class changes while your app runs, because factors like device orientation can change at runtime."

Lesson 23 · question 4 of 10 · a split view on a phone held sideways

A two-column NavigationSplitView on an iPhone 17 rotated to landscape — room, you would think, for two columns. What does it show?

import SwiftUI

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]

    @State private var selection: String?

    var body: some View {
        NavigationSplitView {
            List(cities, id: \.self, selection: $selection) { city in
                NavigationLink(city, value: city)
            }
            .navigationTitle("Trips")
        } detail: {
            Text(selection.map { "Detail of \($0)" } ?? "Choose a trip")
        }
    }
}
Show the answer
Screenshot of this code running
Sideways: one column, the listScreenshot · iPhone 17, iOS 27.0

One column — the list. The split view goes by the horizontal size class, not by points, and this iPhone sideways is compact wide (the previous question). Apple: "At narrow size classes, such as on iPhone or Apple Watch, a navigation split view collapses into a single stack."

So you describe the columns once, and the system shows as many as the kind of space allows — never an iPad layout squeezed onto a phone.

Where this comes from: NavigationSplitView · UserInterfaceSizeClass — Measured on an iPhone 17 simulator, rotated to landscape.

Lesson 23 · question 5 of 10 · the first that fits

Two ViewThatFits. In the first, a short text comes before a longer one that would also fit. In the second, the first child is far too long, and is told not to wrap. What does each show?

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 30) {
            ViewThatFits {
                Text("Short")
                Text("A much longer label that would also have fitted")
            }
            ViewThatFits {
                Text("A label far too long to fit on one line of an iPhone screen at this size, so it cannot be chosen")
                    .fixedSize()
                Text("The fallback")
            }
        }
        .padding()
    }
}
Show the answer
Screenshot of this code running
"Short" — and the fallbackScreenshot · iPhone 17, iOS 27.0

"Short", and "The fallback". ViewThatFits goes through its children in order and shows the first whose ideal size fits the space — not the largest one that fits, not the best. So the order is the design: the most generous layout first, the most compact last.

In the second, the long text has .fixedSize(), so it asks for its whole width on one line, does not fit, and is passed over. Without fixedSize, a Text can wrap to fit almost any width, and would have been chosen: ViewThatFits measures what a child wants, and a Text that may wrap wants surprisingly little.

Where this comes from: ViewThatFits — Apple, ViewThatFits: it "evaluates its child views in the order you provide them … and selects the first child whose ideal size on the constrained axes fits within the proposed size."

Lesson 23 · question 6 of 10 · three columns on an iPad held upright

A three-column split view — countries, the cities of the chosen country, the chosen city — runs on an iPad held upright. When it opens, where are the countries? And if the code asks for all three columns with columnVisibility: .all?

import SwiftUI

struct ContentView: View {
    let countries = ["Canada": ["Vancouver", "Victoria"], "Chile": ["Atacama"]]
    @State private var country: String?
    @State private var city: String?

    var body: some View {
        NavigationSplitView {
            List(countries.keys.sorted(), id: \.self, selection: $country) { name in
                Text(name)
            }
            .navigationTitle("Countries")
        } content: {
            if let country {
                List(countries[country] ?? [], id: \.self, selection: $city) { name in
                    Text(name)
                }
                .navigationTitle(country)
            } else {
                Text("Choose a country")
            }
        } detail: {
            Text(city.map { "All about \($0)" } ?? "Choose a city")
        }
    }
}
Show the answer
Screenshot of this code running
Upright: the middle and last columns, and a buttonScreenshot · iPad Air 11-inch (M4), iOS 27.0

Out of sight. Held upright, the iPad shows the second and third columns — "Choose a country", "Choose a city" — and the first sits behind the one button on the screen, Show Sidebar. A tap on it slides the countries in over the other two:

Screenshot of this code running
After Show SidebarScreenshot · iPad Air 11-inch (M4), iOS 27.0

Held sideways, there is room for all three at once:

Screenshot of this code running
Sideways: countries, cities and cityScreenshot · iPad Air 11-inch (M4), iOS 27.0

Asking for all three does not change that:

@State private var columns = NavigationSplitViewVisibility.all
…
NavigationSplitView(columnVisibility: $columns) {
…
.onChange(of: columns) {
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

The console

Console
columns: doubleColumn

SwiftUI wrote back to the binding: the code asked for .all, and the split view set it to .doubleColumn, the first column hidden. Apple's page for the type warns: "Some platforms don’t respect every option." A columnVisibility binding is a request, and the binding tells you what was granted.

So on an iPad held upright, the first thing a user of a three-column app sees has no list in it. Make the middle and last columns say what to do — as "Choose a country" does — or start with two columns.

Where this comes from: NavigationSplitView · NavigationSplitViewVisibility — Measured on an iPad Air 11-inch (M4) simulator: upright and sideways, and with columnVisibility set to .all.

Lesson 23 · question 7 of 10 · the size class inside a sheet

On an iPad — regular width — a view inside a sheet reads horizontalSizeClass. What does it get?

import SwiftUI

struct SizeLabel: View {
    let place: String
    @Environment(\.horizontalSizeClass) private var horizontal

    var body: some View {
        Text("\(place): \(horizontal == .regular ? "regular" : "compact") width")
    }
}

struct ContentView: View {
    @State private var isShown = false

    var body: some View {
        VStack(spacing: 20) {
            SizeLabel(place: "The screen")
            Button("Show") { isShown = true }
            Spacer()
        }
        .padding(.top, 60)
        .sheet(isPresented: $isShown) {
            SizeLabel(place: "The sheet")
        }
    }
}
Show the answer
Screenshot of this code running
Behind, regular; in the sheet, compactScreenshot · iPad Air 11-inch (M4), iOS 27.0

Compact. The screen behind is regular wide, and the sheet — a card in the middle of it — is compact wide. Apple's description is exact: the value "tells you about the amount of horizontal space available to the view that reads it" — the view's space, not the device's.

That is the size class doing its job. The planner's two fields side by side would be cramped in that card; a layout keyed to the size class puts them one above the other there without being told. Code that asks "is this an iPad?" would lay the sheet out as if it had the whole screen.

Where this comes from: horizontalSizeClass · sheet(isPresented:onDismiss:content:) — Measured on an iPad Air 11-inch (M4) simulator.

Lesson 23 · question 8 of 10 · the size class of each column

On an iPad, a split view has a view in its sidebar and another in its detail column, and each reads horizontalSizeClass. What does each one get?

import SwiftUI

struct SizeLabel: View {
    let place: String
    @Environment(\.horizontalSizeClass) private var horizontal

    var body: some View {
        Text("\(place): \(horizontal == .regular ? "regular" : "compact") width")
    }
}

struct ContentView: View {
    var body: some View {
        NavigationSplitView {
            List {
                SizeLabel(place: "The sidebar")
            }
            .navigationTitle("Trips")
        } detail: {
            SizeLabel(place: "The detail")
        }
    }
}
Show the answer
Screenshot of this code running
The sidebar, compact; the detail, regularScreenshot · iPad Air 11-inch (M4), iOS 27.0

The sidebar is compact; the detail is regular. Each column is a space of its own, and each is described as what it is: the sidebar is a narrow strip, however large the iPad.

So a row that changes its layout at regular width never gets the chance in a sidebar — on any iPad. Design sidebar rows for a narrow space, and keep the wide layouts for the detail column, where the width is real.

Where this comes from: horizontalSizeClass · NavigationSplitView — Measured on an iPad Air 11-inch (M4) simulator.

Lesson 23 · question 9 of 10 · a selection made before launch

On an iPhone, a split view's selection starts out already set: @State private var selection: String? = "Vancouver". What is on the screen when the app opens?

import SwiftUI

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]

    @State private var selection: String? = "Vancouver"

    var body: some View {
        NavigationSplitView {
            List(cities, id: \.self, selection: $selection) { city in
                NavigationLink(city, value: city)
            }
            .navigationTitle("Trips")
        } detail: {
            Text(selection.map { "Detail of \($0)" } ?? "Choose a trip")
        }
    }
}
Show the answer
Screenshot of this code running
At launch: Vancouver, with a back button to the listScreenshot · iPhone 17, iOS 27.0

Vancouver's detail, with a back button to the list. A collapsed split view, in Apple's words, "shows the last column that displays useful information" — and with a selection already made, that is the detail. The list is one step back.

So setting the selection is how an app opens on one particular item: a trip restored from last time, or one named by a link. It is the same line on both devices — "Programmatic changes that you make to the selection property also affect both the list appearance and the presented detail view" — and the same code on an iPad opens with the list, Vancouver chosen in it, beside Vancouver's detail:

Screenshot of this code running
The same code on an iPad, at launchScreenshot · iPad Air 11-inch (M4), iOS 27.0

Where this comes from: NavigationSplitView — Measured on an iPhone 17 simulator.

Lesson 23 · question 10 of 10 · a detail that keeps the screen

On an iPad held upright, a two-column split view shows the list beside the detail. What does adding .navigationSplitViewStyle(.prominentDetail) change?

import SwiftUI

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]

    @State private var selection: String?

    var body: some View {
        NavigationSplitView {
            List(cities, id: \.self, selection: $selection) { city in
                NavigationLink(city, value: city)
            }
            .navigationTitle("Trips")
        } detail: {
            Text(selection.map { "Detail of \($0)" } ?? "Choose a trip")
        }
        .navigationSplitViewStyle(.prominentDetail)
    }
}
Show the answer
Screenshot of this code running
With prominentDetail: the detail has the screenScreenshot · iPad Air 11-inch (M4), iOS 27.0

The list goes behind a button. With .prominentDetail, the detail keeps the whole width, and the list waits behind Show Sidebar. Without the modifier, the same iPad shows both:

} detail: {
    Text(selection.map { "Detail of \($0)" } ?? "Choose a trip")
}
Screenshot of this code running
Screenshot · iPad Air 11-inch (M4), iOS 27.0

Apple describes the two styles by what happens to the detail: prominentDetail "attempts to maintain the size of the detail content when hiding or showing the leading columns"; balanced "reduces the size of the detail content to make room when showing the leading column or columns."

Choose by what the screen is for. A detail people look at — a photo, a map, a long text — deserves to stay prominent. A detail people move through quickly from the list, as in Mail, is better with the list in view.

Where this comes from: navigationSplitViewStyle(_:) · NavigationSplitView — Measured on an iPad Air 11-inch (M4) simulator held upright.

Sheets, alerts, and popovers

Lesson 23 · question 1 of 10 · the sheet that shows the old value

A row's button sets chosen = city, then isShown = true. The .sheet(isPresented:) shows Text("Editing \(chosen)"). The user taps Edit Vancouver. What does the sheet say — and why?

import SwiftUI

struct ContentView: View {
    let cities = ["Seattle", "Vancouver"]
    @State private var chosen = "nothing"
    @State private var isShown = false

    var body: some View {
        List(cities, id: \.self) { city in
            Button("Edit \(city)") {
                chosen = city
                isShown = true
            }
        }
        .sheet(isPresented: $isShown) {
            Text("Editing \(chosen)")
        }
    }
}
Show the answer
Screenshot of this code running
"Editing nothing"Screenshot · iPhone 17, iOS 27.0

"Editing nothing" — a well-known SwiftUI bug, still there in iOS 27. The sheet's content closure was made the last time body ran, and it holds chosen as it was then. Setting chosen did not make body run again, because body itself never reads chosen — only the closure does. Setting isShown then presented the sheet with the old closure. (Lesson 22's edge cases met the same thing in a navigationDestination.)

The fix is the form made for this — the sheet is handed the item:

@State private var editing: City?
…
Button("Edit \(city.name)") { editing = city }
…
.sheet(item: $editing) { city in
    Text("Editing \(city.name)")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

"Editing Vancouver." With .sheet(item:) there is one piece of state instead of two, the sheet cannot be shown without its item, and the item arrives as the closure's argument. Use it whenever a sheet is about one particular thing.

Where this comes from: sheet(item:onDismiss:content:) · sheet(isPresented:onDismiss:content:) — Measured on iOS 27: the sheet read the value from before the button's action.

Lesson 23 · question 2 of 10 · two sheets on one view

One view has two .sheet modifiers, each with its own Boolean. Does the second one work? (In early SwiftUI it famously did not.)

import SwiftUI

struct ContentView: View {
    @State private var first = false
    @State private var second = false

    var body: some View {
        VStack(spacing: 20) {
            Button("First sheet") { first = true }
            Button("Second sheet") { second = true }
        }
        .sheet(isPresented: $first) {
            Button("Done 1") { first = false }
        }
        .sheet(isPresented: $second) {
            Text("The second sheet")
        }
    }
}
Show the answer
Screenshot of this code running
The second sheetScreenshot · iPhone 17, iOS 27.0

Both work. The first sheet opened and closed, then the second opened. In SwiftUI's first year only the last .sheet on a view was honored, and "put each sheet on a different view" became folklore that still turns up in code reviews.

There is a better reason to prefer one modifier today: two Booleans can both be true at once. A single .sheet(item:) holding an enum of the sheets a screen can show makes "two at once" impossible to write.

Where this comes from: sheet(isPresented:onDismiss:content:) · sheet(item:onDismiss:content:) — Measured on iOS 27.

Lesson 23 · question 3 of 10 · a sheet that will not be swiped away

A sheet must not close until the user has decided. What stops a downward drag — and does a full-screen cover need the same?

import SwiftUI

struct ContentView: View {
    @State private var isShown = false

    var body: some View {
        Button("Show") { isShown = true }
            .sheet(isPresented: $isShown) {
                Text("You must choose")
                    .interactiveDismissDisabled()
            }
    }
}
Show the answer
Screenshot of this code running
After a drag down: still thereScreenshot · iPhone 17, iOS 27.0

.interactiveDismissDisabled(), on the sheet's content. The same drag on an ordinary sheet:

.sheet(isPresented: $isShown) {
    Text("A sheet you may swipe away")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

closed it, back to the Show button. With the modifier, the sheet stayed. Apple: "You typically do this to prevent the user from dismissing a presentation before providing needed data or completing a required action." It takes a Boolean, so it can lift once the form is complete: .interactiveDismissDisabled(!agreed).

A full-screen cover does not need it — it cannot be dragged away at all:

.fullScreenCover(isPresented: $isShown) {
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Still there after the same drag. Which is also its warning: a cover must offer its own way out, or the user is stuck in it.

Where this comes from: interactiveDismissDisabled(_:) · fullScreenCover(isPresented:onDismiss:content:) — Apple, interactiveDismissDisabled(_:): "a user can dismiss a sheet by dragging it down".

Lesson 23 · question 4 of 10 · `dismiss` inside a sheet's stack

A sheet holds a NavigationStack. Its second screen has a Close button that calls @Environment(\.dismiss). The user taps Close. What closes?

import SwiftUI

struct MoreScreen: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(spacing: 20) {
            Text("The second screen of the sheet")
            Button("Close") { dismiss() }
        }
    }
}

struct ContentView: View {
    @State private var isShown = false

    var body: some View {
        Button("Show") { isShown = true }
            .sheet(isPresented: $isShown) {
                NavigationStack {
                    VStack(spacing: 20) {
                        Text("The first screen of the sheet")
                        NavigationLink("More") { MoreScreen() }
                    }
                }
            }
    }
}
Show the answer
Screenshot of this code running
After Close: the sheet is still up, on its first screenScreenshot · iPhone 17, iOS 27.0

Only the second screen. The sheet stays, showing its first screen. Apple lists what dismiss does — "Dismiss a modal presentation, like a sheet or a popover", "Pop the current view from a NavigationStack" — and adds: "The specific behavior of the action depends on where you call it from." Read in a pushed screen, it is that push.

So closing the whole sheet from deep inside needs the sheet's own state: pass its Boolean (or the item binding) down, and set it to false. Interviewers like this as the follow-up to lesson 19's dismiss card: what does it dismiss?

Where this comes from: DismissAction · sheet(isPresented:onDismiss:content:) — Measured on iOS 27: dismiss() in a pushed screen popped it and left the sheet open.

Lesson 23 · question 5 of 10 · a sheet on a phone held sideways

A sheet with .presentationDetents([.medium, .large]) is shown on an iPhone held sideways. How much of the screen does it take?

import SwiftUI

struct ContentView: View {
    @State private var isShown = false

    var body: some View {
        Button("Show") { isShown = true }
            .sheet(isPresented: $isShown) {
                Text("A sheet, on an iPhone held sideways")
                    .presentationDetents([.medium, .large])
            }
    }
}
Show the answer
Screenshot of this code running
Sideways: the sheet covers everythingScreenshot · iPhone 17, iOS 27.0

All of it. Apple: "In vertically compact environments, such as iPhone in landscape orientation, a sheet presentation automatically adapts to appear as a full-screen cover." A phone held sideways is compact tall (lesson 23 measured it), so the detents are set aside.

Two consequences. A sheet you expect people to swipe down has no visible way out when sideways — give it a Cancel or a Done. And the same page names the way to override the adaptation, presentationCompactAdaptation(_:), for a sheet that must stay a sheet.

Where this comes from: sheet(isPresented:onDismiss:content:) · presentationCompactAdaptation(_:) — Apple, sheet(isPresented:onDismiss:content:): "Use the presentationCompactAdaptation(_:) or presentationCompactAdaptation(horizontal:vertical:) modifier to override this behavior."

Lesson 23 · question 6 of 10 · a popover on an iPhone

.popover(isPresented:) — a small bubble on an iPad. What does it become on an iPhone, and how do you keep it a bubble?

import SwiftUI

struct ContentView: View {
    @State private var isShown = false

    var body: some View {
        Button("Info") { isShown = true }
            .popover(isPresented: $isShown) {
                Text("A popover")
                    .padding()
            }
    }
}
Show the answer
Screenshot of this code running
On an iPhone: a sheetScreenshot · iPhone 17, iOS 27.0

A sheet. Apple: "On iPhone, popovers adapt into sheets." To keep the bubble, say so on its content:

Text("A popover")
    .padding()
    .presentationCompactAdaptation(.popover)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The bubble, pointing at its button. Keep it for small, quick content — a hint, a few choices. Anything with a form or a list is better off as the sheet the system chose.

Where this comes from: popover(isPresented:attachmentAnchor:arrowEdge:content:) · presentationCompactAdaptation(_:) — Measured on an iPhone 17 simulator with iOS 27.

Lesson 23 · question 7 of 10 · a confirmation dialog in iOS 27

A confirmationDialog with one destructive button, on an iPhone. Where does it appear, and how does the user say no?

import SwiftUI

struct ContentView: View {
    @State private var isAsking = false

    var body: some View {
        Button("Delete Seattle") { isAsking = true }
            .confirmationDialog("Delete Seattle?", isPresented: $isAsking, titleVisibility: .visible) {
                Button("Delete", role: .destructive) { }
            }
    }
}
Show the answer
Screenshot of this code running
A bubble beside the button, with no CancelScreenshot · iPhone 17, iOS 27.0

In a bubble anchored to the button that opened it, with no Cancel button at all — the buttons on the screen were Delete Seattle and the dialog's Delete. A tap anywhere outside the bubble is the no.

Apple's reference still describes this as the iPad's way: "In regular size classes in iOS, the system renders confirmation dialogs as a popover that the user dismisses by tapping anywhere outside the popover, rather than displaying the standard dismiss action." On iOS 27 an iPhone, compact wide, did the same.

That is why the API is written in roles, not positions: .destructive for what cannot be undone, .cancel for the safe way out if you add one — "If you provide a button with a role of cancel, that button takes the place of the default dismiss action." The system decides how each looks and where it goes.

Where this comes from: confirmationDialog(_:isPresented:titleVisibility:actions:) — Measured on an iPhone 17 simulator with iOS 27.

Lesson 23 · question 8 of 10 · what closes an alert

An alert has a destructive Delete and a Cancel. The user taps Delete. What must your code do to close the alert?

import SwiftUI

struct ContentView: View {
    @State private var cities = ["Seattle", "Vancouver"]
    @State private var isAsking = false

    var body: some View {
        VStack(spacing: 20) {
            ForEach(cities, id: \.self) { Text($0) }
            Button("Delete Seattle") { isAsking = true }
        }
        .alert("Delete Seattle?", isPresented: $isAsking) {
            Button("Delete", role: .destructive) { cities.removeAll { $0 == "Seattle" } }
            Button("Cancel", role: .cancel) { }
        } message: {
            Text("This cannot be undone.")
        }
    }
}
Show the answer
Screenshot of this code running
The alertScreenshot · iPhone 17, iOS 27.0

Nothing. Every button in an alert closes it after its action runs, and SwiftUI sets isAsking back to false. The tap on Delete removed Seattle, and the alert went:

Screenshot of this code running
After Delete: Vancouver alone, and no alertScreenshot · iPhone 17, iOS 27.0

Apple: "All actions in an alert dismiss the alert after the action runs." Roles do the styling — .destructive in red, .cancel as the safe choice. Write no buttons at all and "the system includes a standard “OK” action"; but "No default cancel action is provided", so a question that can be refused needs its Cancel written out.

What you cannot do is keep an alert open after a tap. Anything that takes more than one step belongs in a sheet.

Where this comes from: alert(_:isPresented:actions:message:) — Measured on iOS 27.

Lesson 23 · question 9 of 10 · `dismiss` read in the wrong view

A view declares @Environment(\.dismiss) private var dismiss, and the sheet it presents has a Close button that calls that dismiss(). The user taps Close. What happens?

import SwiftUI

struct ContentView: View {
    @State private var isShown = false

    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Show") { isShown = true }
            .sheet(isPresented: $isShown) {
                VStack(spacing: 20) {
                    Text("The sheet")
                    Button("Close") { dismiss() }
                }
            }
    }
}
Show the answer
Screenshot of this code running
After Close: the sheet is still thereScreenshot · iPhone 17, iOS 27.0

Nothing — the sheet stays. The dismiss was read by the view that presents the sheet, so it is that view's action, and that view is not presented by anything. Apple's page for DismissAction describes this exact mistake: "the sheet fails to dismiss because the action applies to the environment where you declared it" — and "The dismiss action has no effect on a view that isn't currently presented."

Read it inside the sheet, in a view of its own:

import SwiftUI

struct SheetContents: View {
    @Environment(\.dismiss) private var dismiss
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Close now closes the sheet, back to the Show button. It is an easy mistake to make when a sheet's content starts as a few lines in the presenting view's closure — and the reason editors such as this lesson's TripEditor are views of their own.

Where this comes from: DismissAction — Measured on iOS 27: the action read outside the sheet did nothing.

Lesson 23 · question 10 of 10 · changing the item while its sheet is up

A sheet is shown with .sheet(item: $editing, onDismiss:). A button inside it sets editing to a different trip while the sheet is up. What happens on the screen — and does onDismiss run?

import SwiftUI

struct City: Identifiable {
    let id: Int
    let name: String
}

struct ContentView: View {
    let cities = [City(id: 1, name: "Seattle"), City(id: 2, name: "Vancouver")]
    @State private var editing: City?

    var body: some View {
        List(cities) { city in
            Button("Edit \(city.name)") { editing = city }
        }
        .sheet(item: $editing, onDismiss: { print("dismissed") }) { city in
            VStack(spacing: 20) {
                Text("Editing \(city.name)")
                Button("Switch to Vancouver") { editing = cities[1] }
            }
            .onAppear { print("appeared:", city.name) }
        }
    }
}
Show the answer
Screenshot of this code running
After the switch: a new sheet, for VancouverScreenshot · iPhone 17, iOS 27.0

The sheet goes down and a new one comes up, for Vancouver — and onDismiss runs in between. The console while Seattle's sheet was up:

Console
appeared: Seattle

and after the switch:

Console
dismissed
appeared: Vancouver

Apple's page says so in one line: "If item changes, the system dismisses the sheet and replaces it with a new one using the same process." onAppear ran again: a new sheet, with a new view in it.

So an editor that should step from one trip to the next without sliding away keeps which trip it shows in its own state, inside one sheet — and code in onDismiss must expect to run when the item merely changed.

Where this comes from: sheet(item:onDismiss:content:) — Apple, sheet(item:onDismiss:content:): "If item changes, the system dismisses the sheet and replaces it with a new one using the same process."

Further reading