Lists, navigation and input · core

Lists, identity and navigation

Why ForEach needs stable ids, what goes wrong when they are not, and how NavigationStack made navigation data-driven.

12 min read14 min practice0/2 exercises4 recall cards

By the end you will be able to

  • Explain view identity and predict what a wrong id breaks
  • Build lists with sections, swipe actions and deletion
  • Drive navigation from a path so it can be saved and restored
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

ForEach(items, id: \.self) over an array of String, and two items happen to be the same word. What goes wrong?

Identity is the whole story

Between two renders, SwiftUI must decide, for each view: is this the same view as before (update it), or a new one (insert it, run its transition, give it fresh state)?

For most views the answer comes from structural identity — position in the hierarchy. Inside a ForEach, that is not enough, because items move. So ForEach needs explicit identity:

ForEach(books) { book in … }                    // Book: Identifiable
ForEach(books, id: \.isbn) { book in … }        // an explicit key path
ForEach(0..<5, id: \.self) { index in … }       // fine: integers are unique

Identifiable is the usual route:

struct Book: Identifiable {
    let id: UUID
    var title: String
}
SwiftUI preview
Run to see the preview.

Sections, swipe actions and deletion

List {
    Section("Reading now") {
        ForEach(current) { BookRow(book: $0) }
    }
    Section("Finished") {
        ForEach(finished) { BookRow(book: $0) }
            .onDelete { indexSet in
                finished.remove(atOffsets: indexSet)
            }
    }
}
.listStyle(.insetGrouped)

onDelete gives you swipe-to-delete and Edit-mode deletion for free. For anything more specific, swipeActions lets you place your own:

.swipeActions(edge: .leading) {
    Button("Archive") { archive(book) }.tint(.blue)
}
.swipeActions(edge: .trailing) {
    Button("Delete", role: .destructive) { delete(book) }
}

In iOS 27 swipeActions works in any scroll container, not only List, when combined with .swipeActionsContainer().

The old NavigationView + NavigationLink(isActive:) scattered navigation state across booleans in different views, and deep linking was near impossible. NavigationStack replaced it with a path you own:

struct ContentView: View {
    @State private var path: [Book] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(books) { book in
                NavigationLink(book.title, value: book)
            }
            .navigationTitle("Library")
            .navigationDestination(for: Book.self) { book in
                BookDetail(book: book)
            }
        }
    }
}
  • NavigationLink(value:) pushes a value, not a view.
  • navigationDestination(for:) maps a value type to the view that shows it.
  • path is ordinary state you can read and write.

That last point is what changed. Because the path is data:

path.append(book)            // push
path.removeLast()            // pop
path.removeAll()             // pop to root
path = [book, chapter]       // deep link straight to a nested screen

Make the path Codable and you can persist it, restoring exactly where the user was. For several destination types, use NavigationPath, a type-erased path that accepts any Hashable value.

NavigationView: state scattered
@State private var showingDetail = false
@State private var showingSettings = false
@State private var selectedBook: Book?

NavigationView {
    NavigationLink(
        destination: Detail(book: selectedBook),
        isActive: $showingDetail
    ) { … }
}
// Deep linking: set several
// booleans in the right order
// and hope.
NavigationStack: state owned
@State private var path = NavigationPath()

NavigationStack(path: $path) {
    List(books) { book in
        NavigationLink(book.title, value: book)
    }
    .navigationDestination(for: Book.self) { … }
    .navigationDestination(for: Settings.self) { … }
}
// Deep linking:
// path.append(book)

The right-hand version has one piece of state that fully describes where the user is. That is what makes restoration, deep links and "pop to root" one-liners instead of projects.

Sheets and full-screen covers

Modal presentation is driven by state too:

.sheet(item: $editingBook) { book in
    EditView(book: book)
}
.sheet(isPresented: $showingSettings) {
    SettingsView()
        .presentationDetents([.medium, .large])
}

The item: form is usually better than isPresented: — one optional replaces a boolean plus a separate "which one" variable, so the two cannot disagree. In iOS 27, alerts and confirmation dialogs accept the same item: pattern.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

This code fragment builds ids from the array index. What is the flaw?

struct Item {
    var title: String
}

var items = [Item(title: "one"), Item(title: "two"), Item(title: "three")]

// Simulating ForEach(items.indices, id: \.self)
for index in items.indices {
    print("row \(index): \(items[index].title)")
}

items.remove(at: 0)
print("--- after deleting the first ---")
for index in items.indices {
    print("row \(index): \(items[index].title)")
}
Check yourself

Why is NavigationStack(path:) better than the old NavigationLink(isActive:)?

Give items a stable identity

Write the code

Note needs a stable identity so rows survive edits and reordering. Make it Identifiable and print each id and title.

Solution unlocks after 3 attempts

Drive navigation from a path

Write the code

Model a navigation path as an array and implement push, pop and pop-to-root, printing the path after each operation.

Solution unlocks after 3 attempts
RecallSaved on this device. Never graded.

Without looking: describe what happens to a row's in-progress text edit when the list is re-sorted, in the case where the id is the array index and in the case where it is a stable UUID. Then check yourself.

Checkpoint

You can now:

  • Explain view identity and predict what a wrong id breaks
  • Build lists with sections, swipe actions and deletion
  • Drive navigation from a path, and say why that enables deep links and restoration
  • Choose .sheet(item:) over a boolean pair

Next up: persistence with SwiftData, and testing what you have built.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.