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

Every trip, in a list

List and ForEach

You will buildA second tab in Trips: every trip as a row of a List, in sections by month, with a swipe to delete and a swipe to mark a favorite — and, on the way, the swipe to delete that deletes the wrong trip.

TimeAbout 80 minutes

WhyList is to SwiftUI what UITableView was to UIKit: the scrolling column of rows that most apps are built around, with the system's look, swipe actions, sections and editing for free. It is also where lesson 20's identity rules do the most work, because rows come and go, move and are deleted. Interviewers ask about both halves: how to build it, and what goes wrong when the ids or the offsets are not what you think.

Step 1

Open your project

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

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

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

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

A second tab

The overview — planner, pictures, summary, cards — stays as it is. Every trip in a plain list gets a screen of its own beside it. Choose File ▸ New ▸ File from Template… (⌘N), under User Interface select SwiftUI View, click Next, name it TripList, and click Create. Replace the struct Xcode wrote, and its preview:

New fileIn TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    var body: some View {        Text("Hello, World!")        List(store.trips) { trip in            Text(trip.city)        }    }}
struct TripList: View {
    @Environment(TripStore.self) private var store

    var body: some View {
        List(store.trips) { trip in
            Text(trip.city)
        }
    }
}
In TripList.swift, replace the preview with this
#Preview {    TripList()        .environment(TripStore())}
#Preview {
    TripList()
        .environment(TripStore())
}

List(store.trips) { trip in … } is a List with a ForEach built in: one row for each trip, each known by its id. Now give the app two tabs. In TripsApp.swift, the window shows a TabView holding one Tab for each screen, and the store goes into the environment of the whole TabView, so that both tabs find it:

In TripsApp.swift, make these changes
import SwiftUI@mainstruct TripsApp: App {    @Environment(\.scenePhase) private var scenePhase    @State private var store = TripStore()    var body: some Scene {        WindowGroup {            ContentView()                .environment(store)                .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()                    }            TabView {                Tab("Overview", systemImage: "square.grid.2x2") {                    ContentView()                }                Tab("Trips", systemImage: "list.bullet") {                    TripList()                }            }            .environment(store)            .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()                }            }        }        .onChange(of: scenePhase) { _, new in            if new == .background {                store.save()            }        }    }}
import SwiftUI

@main
struct TripsApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var store = TripStore()

    var body: some Scene {
        WindowGroup {
            TabView {
                Tab("Overview", systemImage: "square.grid.2x2") {
                    ContentView()
                }
                Tab("Trips", systemImage: "list.bullet") {
                    TripList()
                }
            }
            .environment(store)
            .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()
                }
            }
        }
        .onChange(of: scenePhase) { _, new in
            if new == .background {
                store.save()
            }
        }
    }
}

Run the app, and tap Trips at the bottom of the screen:

The Trips tab: one row for each trip — screenshot
The Trips tab: one row for each tripScreenshot · iPhone 17, iOS 27.0

Three rows in the system's style, grouped on a gray background, ready to scroll. The Tab initializer takes a title and an SF Symbol, as a Label does; TabView draws the bar.

Step 3

A row of its own

A row can be any view. Give a trip a row with its picture, its dates and its star. Make another SwiftUI View, named TripRow:

New fileIn TripRow.swift, replace the TripRow struct with this
struct TripRow: View {    let trip: Trip    @Environment(\.highlight) private var highlight    var body: some View {        Text("Hello, World!")        HStack(spacing: 12) {            Image(trip.imageName)                .resizable()                .scaledToFill()                .frame(width: 56, height: 40)                .clipShape(RoundedRectangle(cornerRadius: 6))            VStack(alignment: .leading, spacing: 2) {                Text(trip.city)                    .font(.headline)                Text(trip.start, format: .dateTime.day().month().year())                    .font(.subheadline)                    .foregroundStyle(.secondary)            }            Spacer()            if trip.isFavorite {                Image(systemName: "star.fill")                    .foregroundStyle(highlight)                    .accessibilityLabel("\(trip.city) is a favorite")            }        }    }}
struct TripRow: View {
    let trip: Trip
    @Environment(\.highlight) private var highlight

    var body: some View {
        HStack(spacing: 12) {
            Image(trip.imageName)
                .resizable()
                .scaledToFill()
                .frame(width: 56, height: 40)
                .clipShape(RoundedRectangle(cornerRadius: 6))
            VStack(alignment: .leading, spacing: 2) {
                Text(trip.city)
                    .font(.headline)
                Text(trip.start, format: .dateTime.day().month().year())
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            if trip.isFavorite {
                Image(systemName: "star.fill")
                    .foregroundStyle(highlight)
                    .accessibilityLabel("\(trip.city) is a favorite")
            }
        }
    }
}
In TripRow.swift, replace the preview with this
#Preview {    TripRow()    List {        TripRow(trip: .seattle)        TripRow(trip: .vancouver)    }}
#Preview {
    List {
        TripRow(trip: .seattle)
        TripRow(trip: .vancouver)
    }
}

The star reads lesson 19's highlight from the environment, like everything else in Trips that marks a favorite, and its accessibility label says whose star it is, so that VoiceOver reads "Seattle is a favorite". The preview puts two rows in a List of their own, so that the canvas shows them as rows. In TripList.swift, use it:

In TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    var body: some View {        List(store.trips) { trip in            Text(trip.city)            TripRow(trip: trip)        }    }}
struct TripList: View {
    @Environment(TripStore.self) private var store

    var body: some View {
        List(store.trips) { trip in
            TripRow(trip: trip)
        }
    }
}

Run the app, and tap Trips:

Rows with pictures, dates and stars — screenshot
Rows with pictures, dates and starsScreenshot · iPhone 17, iOS 27.0
Step 4

Sections

A long list reads better in groups. Group the trips by the month they start in. First, a way to ask a date for its month: add to the end of Trip.swift:

Add to Trip.swift
extension Date {
    /// The first moment of the month this date is in.
    var startOfMonth: Date {
        Calendar.current.dateInterval(of: .month, for: self)!.start
    }
}

Then the list. Instead of one ForEach over the trips, a ForEach over the months, each making a Section with a header, and inside each section a ForEach over that month's trips. In TripList.swift:

In TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    var body: some View {        List(store.trips) { trip in            TripRow(trip: trip)        List {            ForEach(months, id: \.self) { month in                let inMonth = trips(in: month)                Section(month.formatted(.dateTime.month(.wide).year())) {                    ForEach(inMonth) { trip in                        TripRow(trip: trip)                    }                }            }        }    }    /// 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

    var body: some View {
        List {
            ForEach(months, id: \.self) { month in
                let inMonth = trips(in: month)
                Section(month.formatted(.dateTime.month(.wide).year())) {
                    ForEach(inMonth) { trip in
                        TripRow(trip: trip)
                    }
                }
            }
        }
    }

    /// 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 }
    }
}

Run the app, and tap Trips:

Two sections: June 2027 and November 2027 — screenshot
Two sections: June 2027 and November 2027Screenshot · iPhone 17, iOS 27.0

Two things to notice. The outer ForEach says id: \.self, because a month is a Date, which has no id — and that is safe, because no two months in months are equal: it is made from a Set. And let inMonth = trips(in: month) sits inside the ForEach's closure, before the Section, so that each section works out its trips once. The next step needs it.

Step 5

Swipe to delete — the wrong way first

Deleting a row by swiping it to the left is one modifier on the ForEach: .onDelete, which hands you the offsets of the rows to delete. The obvious code removes those offsets from the store. In TripList.swift:

In TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    var body: some View {        List {            ForEach(months, id: \.self) { month in                let inMonth = trips(in: month)                Section(month.formatted(.dateTime.month(.wide).year())) {                    ForEach(inMonth) { trip in                        TripRow(trip: trip)                    }                    .onDelete { offsets in                        store.trips.remove(atOffsets: offsets)                    }                }            }        }    }    /// 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

    var body: some View {
        List {
            ForEach(months, id: \.self) { month in
                let inMonth = trips(in: month)
                Section(month.formatted(.dateTime.month(.wide).year())) {
                    ForEach(inMonth) { trip in
                        TripRow(trip: trip)
                    }
                    .onDelete { offsets in
                        store.trips.remove(atOffsets: offsets)
                    }
                }
            }
        }
    }

    /// 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 }
    }
}

Run the app, tap Trips, and swipe San Pedro de Atacama to the left:

San Pedro de Atacama was swiped and deleted — and Seattle is the trip that went — screenshot
San Pedro de Atacama was swiped and deleted — and Seattle is the trip that wentScreenshot · iPhone 17, iOS 27.0
The swipe: a red Delete button — screenshot
The swipe: a red Delete buttonScreenshot · iPhone 17, iOS 27.0

Then tap Delete — the picture at the top of this step. Atacama is still there, and Seattle is gone. The offsets .onDelete hands over count rows in that section: Atacama is row 0 of November. store.trips.remove(atOffsets:) counts in the store's array, where row 0 is Seattle. Every list with sections, a filter or a sort has this trap, and nothing warns about it: the offsets are perfectly valid, in the wrong array.

The fix is to turn offsets into trips while you are still in the section that gave them — that is what inMonth is for — and to delete trips, by id, from the store. Add to TripStore a way to delete trips, and, for the next step, a way to make one a favorite. 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()        }    }    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()
        }
    }

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

And in TripList.swift, hand the store the trips the offsets point to:

In TripList.swift, replace the TripList struct with this
struct TripList: View {    @Environment(TripStore.self) private var store    var body: some View {        List {            ForEach(months, id: \.self) { month in                let inMonth = trips(in: month)                Section(month.formatted(.dateTime.month(.wide).year())) {                    ForEach(inMonth) { trip in                        TripRow(trip: trip)                    }                    .onDelete { offsets in                        store.trips.remove(atOffsets: offsets)                        store.delete(offsets.map { inMonth[$0] })                    }                }            }        }    }    /// 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

    var body: some View {
        List {
            ForEach(months, id: \.self) { month in
                let inMonth = trips(in: month)
                Section(month.formatted(.dateTime.month(.wide).year())) {
                    ForEach(inMonth) { trip in
                        TripRow(trip: trip)
                    }
                    .onDelete { offsets in
                        store.delete(offsets.map { inMonth[$0] })
                    }
                }
            }
        }
    }

    /// 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 }
    }
}

Run the app, and make the same swipe and tap:

The same swipe and tap: Atacama is gone, and its section with it — screenshot
The same swipe and tap: Atacama is gone, and its section with itScreenshot · iPhone 17, iOS 27.0

San Pedro de Atacama is gone, and so is November, because months no longer has a trip in it. Nothing told the list to remove the section: the list is a description of the store, and the store changed. So did the overview tab, which reads the same store: its tiles now say 2 trips.

Step 6

A second swipe action

A row can have actions of its own on either edge. .onDelete already owns the trailing edge, so put a favorite on the leading one, with .swipeActions(edge: .leading). A swipe action is a Button; its .tint colors it. 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    var body: some View {        List {            ForEach(months, id: \.self) { month in                let inMonth = trips(in: month)                Section(month.formatted(.dateTime.month(.wide).year())) {                    ForEach(inMonth) { trip in                        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] })                    }                }            }        }    }    /// 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

    var body: some View {
        List {
            ForEach(months, id: \.self) { month in
                let inMonth = trips(in: month)
                Section(month.formatted(.dateTime.month(.wide).year())) {
                    ForEach(inMonth) { trip in
                        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] })
                    }
                }
            }
        }
    }

    /// 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 }
    }
}

Run the app, tap Trips, and swipe Vancouver to the right:

After the tap on Favorite: Vancouver has a star — screenshot
After the tap on Favorite: Vancouver has a starScreenshot · iPhone 17, iOS 27.0
The swipe from the leading edge: Favorite, in the highlight color — screenshot
The swipe from the leading edge: Favorite, in the highlight colorScreenshot · iPhone 17, iOS 27.0

Tap Favorite — the picture at the top of this step: Vancouver has its star. The button's title and symbol come from the trip, so the same swipe on a favorite offers Unfavorite. Why the leading edge? A .swipeActions on the trailing edge would take that edge over, and .onDelete's Delete would no longer appear there. One of this lesson's edge cases shows it happening.

What you built

Guide: how ForEach works — what it keeps, when it makes its rows and how it tells them apart, all run on the simulator — is in ForEach, in full.

The list has rows but nowhere to go. In the next lesson a tap on a row opens the trip on a screen of its own, in a NavigationStack — and the stack of screens turns out to be an array you own.

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

17 cards for this lesson, each labelled with its topic. 9 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 · ListA scrolling list with one row for each trip in an array.
List(trips) { trip in
    Text(trip.city)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

List(data) { item in … }, with Identifiable items — or List(data, id: \.someKey). It is a ForEach inside a List, written in one line.

Syntax · ListRows in groups, each group with a header.
Section(month) {
    ForEach(trips.filter { $0.month == month }) { trip in
        Text(trip.city)
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Section("Header") { … } inside a List. Sections can come from a ForEach of their own, as here, one for each month.

Syntax · ListLet the user delete a row by swiping it to the left.
.onDelete { offsets in
    cities.remove(atOffsets: offsets)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.onDelete on the ForEach — not on the List. It hands you the offsets of the rows, in the collection that ForEach was given; remove them from the same collection. The picture is the swipe; after the tap on Delete, Seattle and Atacama were left.

Syntax · ListA swipe action of your own, from the leading edge.
.swipeActions(edge: .leading) {
    Button("Pin", systemImage: "pin") { pinned = city }
        .tint(.orange)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.swipeActions(edge:) on the row, holding Buttons; .tint colors each. The default edge is .trailing. The picture is the swipe from the leading edge.

Syntax · ListLet the user reorder rows, with the system's Edit button.
.onMove { cities.move(fromOffsets: $0, toOffset: $1) }
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.onMove on the ForEach, with Array.move(fromOffsets:toOffset:). In edit mode — the Edit button turns it on — each row shows a delete control and a handle to drag. The picture is the list in edit mode.

Syntax · TabViewTwo screens, side by side, with a tab bar to switch between them.
TabView {
    Tab("Overview", systemImage: "square.grid.2x2") {
        Text("The overview")
    }
    Tab("Trips", systemImage: "list.bullet") {
        Text("All the trips")
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

TabView holding a Tab for each screen, each with a title and an SF Symbol. The picture is after a tap on Trips.

Syntax · ListA list that mixes fixed rows with rows made from data.
List {
    Text("Every trip")
        .font(.headline)
    ForEach(cities, id: \.self) { city in
        Text(city)
    }
    Text("\(cities.count) in all")
        .foregroundStyle(.secondary)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The List { … } form takes any rows: fixed ones written out, and a ForEach for the data. List(data) { … } takes only the data.

Syntax · ListA list without the grouped, inset look.
.listStyle(.plain)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.listStyle(.plain): rows edge to edge, with no gray background. On iPhone the default is .insetGrouped, which Trips uses; .grouped, .inset and .sidebar are the others.

Syntax · ListiOS 27: swipe actions on rows that are not in a List — and only one row open at a time.
Text(city)
    …
    .swipeActions {
        Button("Delete \(city)", systemImage: "trash", role: .destructive) {
            cities.removeAll { $0 == city }
        }
    }
…
.swipeActionsContainer()
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.swipeActions now works on rows in a ScrollView; .swipeActionsContainer() on the scroll view gives them what List gives its rows — one open at a time, closed by scrolling. The picture is the swipe; the tap deleted Vancouver.

Trick question · ListSections by month, and .onDelete { trips.remove(atOffsets: offsets) } in each. You swipe Atacama, alone in November, and tap Delete. What goes?
.onDelete { offsets in
    trips.remove(atOffsets: offsets)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Seattle. The offsets count rows in that section — Atacama is row 0 of November — and trips counts from Seattle. Turn offsets into items in the section that gave them, then delete those items from the store by id.

Trick question · ListA row has .onDelete and also a Flag action from .swipeActions(edge: .trailing). You swipe it to the left. What do you see?
.swipeActions(edge: .trailing) {
    Button("Flag", systemImage: "flag") { print("flag \(city)") }
        .tint(.orange)
}
…
.onDelete { cities.remove(atOffsets: $0) }
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Flag, and no Delete. Swipe actions on an edge replace what .onDelete puts there. Add a destructive Delete button to the same .swipeActions, or put the other action on the leading edge.

Trick question · ListA List of three cities, put inside a ScrollView between a title and a footer. What does the screen show?
ScrollView {
    Text("Trips")
        .font(.largeTitle)
    List(cities, id: \.self) { city in
        Text(city)
    }
    Text("The end of the screen")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The title and the footer — no cities. A List scrolls by itself and takes the height it is offered; a ScrollView offers its content no fixed height, so the list gets almost none. Make the title and footer rows of the List, or use a LazyVStack in the scroll view.

Trick question · ListList(cities, id: \.self) { … }.onDelete { … }. Does it compile?
List(cities, id: \.self) { city in
    Text(city)
}
.onDelete { cities.remove(atOffsets: $0) }

No: "value of type 'List<Never, ForEach<[String], String, Text>>' has no member 'onDelete'" onDelete belongs to ForEach — dynamic content — not to List. Write List { ForEach(…) { … }.onDelete { … } }.

Trick question · ListA List row holds a city and two buttons, Share and Delete. The user taps the city's name. What runs?
HStack {
    Text("Seattle")
    Spacer()
    Button("Share") { print("Share tapped") }
    Button("Delete") { print("Delete tapped") }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Both buttons. The console: Share tapped, Delete tapped. In a List, plain buttons make the whole row one tappable cell, and a tap on the row fires every button in it. Give each button .buttonStyle(.borderless) and each answers only to taps on itself.

Trick question · ListList(…, selection: $selected) where selected is a Set — multiple selection. On an iPhone, you tap Vancouver, then Seattle. What is selected?
@State private var selected = Set<String>()
…
List(cities, id: \.self, selection: $selected) { city in
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Only Seattle. Outside edit mode, a tap on an iPhone selects one row, whatever the binding can hold. Apple: "To enable multiple selections with tap gestures, put the list into edit mode." With an EditButton turned on, the same taps selected both.

Trick question · ListAn .onDelete that only prints, and forgets to remove the item. The user swipes Vancouver and taps Delete. What does the list show?
.onDelete { offsets in
    print("asked to delete \(offsets.map { cities[$0] })")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Seattle and Atacama — Vancouver's row is gone, and Vancouver is still in cities. Apple: the action "will be called after the row has already been removed from the List". The screen and the data now disagree, until something redraws the list.

Trick question · TabViewA counter in the first tab. Tap it twice, switch to the second tab, and come back. What does it say?
Tab("First", systemImage: "1.circle") {
    Counter()
}
Tab("Second", systemImage: "2.circle") {
    Text("The second tab")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Count: 2. A TabView keeps every tab it has shown, state and all, while another is in front — unlike an if, which would end the view.

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 21 · question 1 of 9 · whose offsets?

.onDelete hands you offsets. Two lists that delete with them — one in sections, one sorted on the fly with cities.sorted(). In each, Atacama is swiped and deleted. What actually goes?

import SwiftUI

struct Trip: Identifiable {
    let id: Int
    let city: String
    let month: String
}

struct ContentView: View {
    @State private var trips = [Trip(id: 1, city: "Seattle", month: "June"), Trip(id: 2, city: "Vancouver", month: "June"),
                                Trip(id: 3, city: "Atacama", month: "November")]

    var body: some View {
        List {
            ForEach(["June", "November"], id: \.self) { month in
                Section(month) {
                    ForEach(trips.filter { $0.month == month }) { trip in
                        Text(trip.city)
                    }
                    .onDelete { offsets in
                        trips.remove(atOffsets: offsets)
                    }
                }
            }
        }
    }
}
Show the answer
Screenshot of this code running
In sections: Atacama is still there; Seattle wentScreenshot · iPhone 17, iOS 27.0

In both, Seattle. The offsets count positions in the collection that that ForEach was given: the November section's trips, or the sorted copy. Atacama is position 0 in both. remove(atOffsets:) on the original array removes its position 0 — Seattle.

The sorted list does the same:

ForEach(cities.sorted(), id: \.self) { city in
    Text(city)
}
.onDelete { cities.remove(atOffsets: $0) }
Screenshot of this code running
Sorted on the fly: Atacama is still there; Seattle wentScreenshot · iPhone 17, iOS 27.0

The rule: turn offsets into items in the same collection that produced them, then delete those items from the store by id.

.onDelete { offsets in
    let doomed = offsets.map { trips.filter { $0.month == month }[$0].id }
    trips.removeAll { doomed.contains($0.id) }
}
Screenshot of this code running
Offsets turned into ids first: Atacama wentScreenshot · iPhone 17, iOS 27.0

This is why a list's data needs stable ids even for deleting: the id is the only thing that means the same in the section, the sorted copy and the store. The interview-preparation repository asks it as "Why are stable IDs important in ForEach / List?" — this is the concrete answer.

Where this comes from: onDelete(perform:) · ForEach · Senior iOS Interview Preparation (GitHub) — Apple, onDelete(perform:): "You must delete the corresponding item within action."

Lesson 21 · question 2 of 9 · which edge belongs to whom

A row has .onDelete, and you add a Flag swipe action. Where you put the action decides what the user can still do. Swipe left, with Flag on the trailing edge — and with Flag on the leading edge.

import SwiftUI

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

    var body: some View {
        List {
            ForEach(cities, id: \.self) { city in
                Text(city)
                    .swipeActions(edge: .trailing) {
                        Button("Flag", systemImage: "flag") { print("flag \(city)") }
                            .tint(.orange)
                    }
            }
            .onDelete { cities.remove(atOffsets: $0) }
        }
    }
}
Show the answer
Screenshot of this code running
Flag on the trailing edge: the left swipe shows Flag onlyScreenshot · iPhone 17, iOS 27.0

With Flag on the trailing edge, Delete is gone. A row's swipe actions for an edge replace what .onDelete puts on that edge, so swiping left now offers Flag alone, and the user can no longer delete by swiping.

Move Flag to the leading edge, and the left swipe offers Delete again:

.swipeActions(edge: .leading) {
    Button("Flag", systemImage: "flag") { print("flag \(city)") }
        .tint(.orange)
}
Screenshot of this code running
Flag on the leading edge: the left swipe shows DeleteScreenshot · iPhone 17, iOS 27.0

If both should be on the trailing edge, write Delete yourself among the swipe actions: Button("Delete", systemImage: "trash", role: .destructive) { … }. Apple's documentation shows exactly that pair, Delete and Flag, and notes that "actions appear in the order you list them, starting from the swipe’s originating edge" — and that the first can be performed with a full swipe, which makes the order matter.

Where this comes from: swipeActions(edge:allowsFullSwipe:content:) · onDelete(perform:) — Apple, swipeActions(edge:allowsFullSwipe:content:): "By default, the user can perform the first action for a given swipe direction with a full swipe."

Lesson 21 · question 3 of 9 · an `onDelete` that does not delete

.onDelete is written, but its action only logs, perhaps waiting for a server to agree. The user swipes Vancouver and taps Delete. What does the list show? And after something unrelated makes the screen redraw?

import SwiftUI

struct ContentView: View {
    @State private var cities = ["Seattle", "Vancouver", "Atacama"]
    @State private var redraws = 0

    var body: some View {
        VStack {
            Button("Redraw") { redraws += 1 }
            Text("Redraws: \(redraws)")
            List {
                ForEach(cities, id: \.self) { city in
                    Text(city)
                }
                .onDelete { offsets in
                    print("asked to delete \(offsets.map { cities[$0] })")
                }
            }
        }
    }
}
Show the answer
Screenshot of this code running
After Delete: no Vancouver on the screenScreenshot · iPhone 17, iOS 27.0

Vancouver's row is gone, though Vancouver is still in cities:

The console

Console
asked to delete ["Vancouver"]

Apple's documentation is blunt about it: "You must delete the corresponding item within action, as it will be called after the row has already been removed from the List." The list removed the row first and then asked you to make the data agree. You did not, so the screen and the data now disagree — until something makes the screen redraw. A tap on Redraw, which changes only a counter:

Screenshot of this code running
After an unrelated redraw: Vancouver is backScreenshot · iPhone 17, iOS 27.0

Vancouver came back out of nowhere: the list drew what the data says.

So an action that needs permission — a server, an "are you sure?" — cannot live in onDelete. Use a swipe action instead: .swipeActions { Button("Delete", role: .destructive) { … } } runs your code first, and the row goes only when your code removes the item. Lesson 23's sheets and alerts give the "are you sure?" itself.

Where this comes from: onDelete(perform:) · swipeActions(edge:allowsFullSwipe:content:) — Apple, onDelete(perform:): the action "will be called after the row has already been removed from the List."

Lesson 21 · question 4 of 9 · a `List` inside a `ScrollView`

A screen with a big title, a List of trips, and a footer, all inside a ScrollView so that they scroll together. What does it show?

import SwiftUI

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

    var body: some View {
        ScrollView {
            Text("Trips")
                .font(.largeTitle)
            List(cities, id: \.self) { city in
                Text(city)
            }
            Text("The end of the screen")
        }
    }
}
Show the answer
Screenshot of this code running
The title and the footer, and nothing between themScreenshot · iPhone 17, iOS 27.0

The title and the footer, and no trips. A List is a scroll view itself, and takes the height it is offered. Inside a ScrollView, which measures its content along the scrolling axis without limit, the list is offered no particular height, and it ends up with next to none — not one of its rows appeared. No error and no warning: an empty space where the list should be.

Make everything rows of the one List:

List {
    Text("Trips")
        .font(.largeTitle)
    ForEach(cities, id: \.self) { city in
        Text(city)
    }
    Text("The end of the screen")
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Or, if the screen is not really a list, keep the ScrollView and put the rows in a LazyVStack, as Trips' overview does. What does not work is one scrolling container inside another along the same axis, each expecting the other to decide its height.

Where this comes from: List · Creating performant scrollable stacks — Measured on iOS 27: the List inside the ScrollView drew none of its rows.

Lesson 21 · question 5 of 9 · `List(data)` or `List { ForEach }`

Two ways to write a list from data: List(cities) { … } and List { ForEach(cities) { … } }. They draw the same thing. Add .onDelete to the first. Does it compile — and when does the difference matter?

List(cities, id: \.self) { city in
    Text(city)
}
.onDelete { cities.remove(atOffsets: $0) }
Show the answer

It does not compile. Xcode reports

value of type 'List<Never, ForEach<[String], String, Text>>' has no member 'onDelete'

It does not compile, and the message says why: List has no onDelete. .onDelete, .onMove and the reordering modifiers belong to ForEach — to dynamic content, which knows how its rows map to a collection. List(data) keeps its ForEach inside, out of reach.

So the one-line form is for lists you only read. As soon as rows can be deleted or moved, or the list mixes fixed rows with data, or has sections, write List { ForEach(…) { … } } — which is also what the one-line form does for you, minus the handle.

Where this comes from: List · ForEach · onDelete(perform:) — Apple: onDelete(perform:) is declared on DynamicViewContent, the protocol ForEach conforms to.

Lesson 21 · question 6 of 9 · two buttons in one row

A row of a List holds a city name and two buttons, Share and Delete. The user taps the name. Which buttons run?

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            HStack {
                Text("Seattle")
                Spacer()
                Button("Share") { print("Share tapped") }
                Button("Delete") { print("Delete tapped") }
            }
        }
    }
}
Show the answer
Screenshot of this code running
One row, two buttonsScreenshot · iPhone 17, iOS 27.0

Both — and the user tapped neither. The console:

The console

Console
Share tapped
Delete tapped

In a List, a button with the default style turns its whole row into one tappable cell, the way a single button in a table cell would. With two in a row, the row answers for both: a tap anywhere on it runs every one. Here that is a share sheet and a deletion, from a tap on a name.

Give each button a style that keeps it to itself:

Button("Share") { print("Share tapped") }
    .buttonStyle(.borderless)
Button("Delete") { print("Delete tapped") }
    .buttonStyle(.borderless)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

A tap on the name, then a tap on Share

Console
Share tapped

The tap on the name ran nothing; the tap on Share ran Share. This is one of the most-reported SwiftUI surprises, and it says something general: inside a List, a row is a cell first, and its contents second.

Where this comes from: List · buttonStyle(_:) — Measured on iOS 27: a tap on the row's text ran both buttons' actions.

Lesson 21 · question 7 of 9 · selection on an iPhone

List(cities, selection: $selected) with selected a Set — multiple selection. On an iPhone, the user taps Vancouver, then Seattle. What is selected? And after tapping an Edit button first?

import SwiftUI

struct ContentView: View {
    let cities = ["Seattle", "Vancouver", "Atacama"]
    @State private var selected = Set<String>()

    var body: some View {
        VStack {
            List(cities, id: \.self, selection: $selected) { city in
                Text(city)
            }
            Text("Selected: \(selected.sorted().joined(separator: ", "))")
        }
    }
}
Show the answer
Screenshot of this code running
Without edit mode: only SeattleScreenshot · iPhone 17, iOS 27.0

Only Seattle. On an iPhone, outside edit mode, a tap selects one row, replacing the last — a Set binding makes no difference. Apple: "To enable multiple selections with tap gestures, put the list into edit mode by either modifying the editMode value, or adding an EditButton to your app’s interface."

EditButton()
List(cities, id: \.self, selection: $selected) { city in
Screenshot of this code running
In edit mode: a circle on each row, and both taps keptScreenshot · iPhone 17, iOS 27.0

With the Edit button on, each row shows a circle, and both taps were kept. On iPad and Mac, Apple adds, people can select several rows without edit mode, using the keyboard and pointer — so the same code behaves differently by device, which is worth saying in an interview.

Where this comes from: List · EditButton — Apple, List: "People can make multiple selections without needing to enter edit mode on devices that have a keyboard and mouse or trackpad, like Mac and iPad."

Lesson 21 · question 8 of 9 · swipe actions outside a `List`

iOS 27 lets rows in a ScrollView have swipe actions. Two rows are swiped open, one after the other. Is the first still open — with .swipeActionsContainer() on the scroll view, and without it?

import SwiftUI

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

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                ForEach(cities, id: \.self) { city in
                    Text(city)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                        .background(.background)
                        .swipeActions {
                            Button("Delete \(city)", systemImage: "trash", role: .destructive) {
                                cities.removeAll { $0 == city }
                            }
                        }
                }
            }
        }
        .swipeActionsContainer()
    }
}
Show the answer
Screenshot of this code running
With the container: only Atacama is openScreenshot · iPhone 17, iOS 27.0

With the container, only the second. Opening Atacama's actions closed Vancouver's. Without it:

ScrollView {
    LazyVStack(spacing: 0) {
        …
    }
}
Screenshot of this code running
Without the container: both rows openScreenshot · iPhone 17, iOS 27.0

Both stayed open. The swipe actions themselves work either way; .swipeActionsContainer() adds the manners a List has built in. Apple lists them: "Only one row’s swipe actions are revealed at a time. Scrolling the container dismisses any open actions. Tapping outside the active row dismisses its actions." And on a List it does nothing, because a list already behaves that way.

So the new answer to "can I have swipe-to-delete without a List?" is yes, since iOS 27, with two modifiers rather than one — and an older app that hand-built swipe gestures on cards can delete them.

Where this comes from: swipeActionsContainer() (iOS 27) · swipeActions(edge:allowsFullSwipe:content:) — Apple, swipeActionsContainer(): "Applying this modifier to a List is a no-op, since List already provides this coordination."

Lesson 21 · question 9 of 9 · tabs and state

A counter in the first tab of a TabView. Tap it twice, switch to the second tab, and come back. What does it say — and why is that different from an if?

import SwiftUI

struct Counter: View {
    @State private var count = 0

    var body: some View {
        Button("Count: \(count)") { count += 1 }
    }
}

struct ContentView: View {
    var body: some View {
        TabView {
            Tab("First", systemImage: "1.circle") {
                Counter()
            }
            Tab("Second", systemImage: "2.circle") {
                Text("The second tab")
            }
        }
    }
}
Show the answer
Screenshot of this code running
Back on the first tab: Count: 2Screenshot · iPhone 17, iOS 27.0

Count: 2. A TabView keeps every tab it has shown alive while another is in front: its views keep their identity, so their state, scroll positions and navigation stacks wait where they were. An if that swapped the two screens would end one and start the other, and the count would be 0 (lesson 20).

It cuts both ways. A tab that has been shown is not cleaned up when the user leaves it — its state stays — so a timer or a subscription started in one tab keeps going while another is in front. And because tabs keep their own navigation stacks (the next lesson), "go back to the top of every tab" has to be done on purpose, by resetting each tab's path.

Where this comes from: TabView · WWDC21: Demystify SwiftUI (2021 — still the reference on identity) — Measured on iOS 27: the first tab's count survived a visit to the second.

Further reading