Lists, identity and navigation
Why ForEach needs stable ids, what goes wrong when they are not, and how NavigationStack made navigation data-driven.
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
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
}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().
NavigationStack: navigation as data
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.pathis 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.
@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.@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.
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)")
}Why is NavigationStack(path:) better than the old NavigationLink(isActive:)?
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.
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.