A list that opens a detail screen
Think of a contacts app. The list shows a row per person; tap a row, and a detail screen slides in with a back button. In SwiftUI:
Listshows rows, one per item in your data. Your items should beIdentifiable, so SwiftUI can tell rows apart even when the data changes.NavigationStackis the container that manages the “slide in, back button” behavior. Screens are stacked on top of each other, like cards.NavigationLink(value:)makes a row tappable and says which item was tapped..navigationDestination(for:)says which screen to show for that kind of item.
NavigationStack models navigation as a stack of values: links append a Hashable value to the stack’s path, and .navigationDestination(for:) maps each value type to a view. That separation (what to navigate to vs how to render it) is what makes programmatic navigation, deep links and state restoration possible: the path is plain state you can mutate, observe and serialize.
List is backed by a collection view: rows are created lazily and reused, and diffing relies on each element’s identity. The Detailed version covers navigation as typed routes, NavigationPath and its serialization limits, destination registration rules, and list identity and performance.
struct Product: Identifiable, Hashable {
let id: Int
let name: String
let price: Double
}
struct CatalogView: View {
let products: [Product]
var body: some View {
NavigationStack {
List(products) { product in
NavigationLink(value: product) {
LabeledContent(product.name, value: product.price, format: .currency(code: "EUR"))
}
}
.navigationTitle("Catalog")
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
}
struct ProductDetailView: View {
let product: Product
var body: some View {
Text(product.name)
.font(.largeTitle)
.navigationTitle(product.name)
.navigationBarTitleDisplayMode(.inline)
}
}Notice the split:
- The row only says “this product” (
NavigationLink(value: product)). It doesn’t create the detail screen. - One
.navigationDestination(for: Product.self)on the list says how to show any product. It’s attached outside theList, so it’s registered once for the whole stack. ProductisHashablebecause navigation values must be, andIdentifiablebecause theListneeds stable row identity: theidfrom your data, never a random value.
The destination in the wrong place
Tap the line with the bug.
NavigationStack {
List(products) { product in
NavigationLink(value: product) {
Text(product.name)
}
.navigationDestination(for: Product.self) { ProductDetailView(product: $0) }
}
}Hint: List only creates the rows that are on screen. Where is each destination registered, and how many times?
The destination is attached to every row, inside a lazy container. List creates rows only when they’re about to appear, so destinations come and go as you scroll, and the same destination is registered once per visible row. SwiftUI logs a runtime warning (“Do not put a navigation destination modifier inside a lazy container”), and taps can do nothing or push the wrong screen, depending on what’s rendered.
Register each destination once, outside lazy containers, on a view that’s always present in the stack:
NavigationStack {
List(products) { product in
NavigationLink(value: product) {
Text(product.name)
}
}
.navigationDestination(for: Product.self) { ProductDetailView(product: $0) }
}Sections, search and swipe to delete
Real lists group rows, filter them and let the user edit them:
struct Todo: Identifiable, Hashable {
let id = UUID() // created once, when the todo is created
var title: String
var isDone = false
}
struct TodoListView: View {
@State private var todos = [Todo(title: "Buy milk"), Todo(title: "Call mom", isDone: true)]
@State private var query = ""
private var matching: [Todo] {
query.isEmpty ? todos : todos.filter { $0.title.localizedStandardContains(query) }
}
var body: some View {
NavigationStack {
List {
Section("To do") {
ForEach(matching.filter { !$0.isDone }) { todo in
Text(todo.title)
.swipeActions {
Button("Delete", role: .destructive) { delete(todo) }
}
}
}
Section("Done") {
ForEach(matching.filter(\.isDone)) { todo in
Text(todo.title).strikethrough()
}
}
}
.searchable(text: $query)
.navigationTitle("Todos")
}
}
private func delete(_ todo: Todo) {
todos.removeAll { $0.id == todo.id } // delete by identity, not by position
}
}.searchable adds a search field to the navigation bar. .swipeActions adds buttons when the user swipes a row. Deleting by id is a deliberate choice, as the next bug shows.
You’ll also often see the older .onDelete modifier on a ForEach: it enables swipe-to-delete and calls your closure with an IndexSet of positions (offsets) of the rows the user deleted, which you typically pass to array.remove(atOffsets:). Positions are counted in the collection the ForEach is showing.
Now the classic list bug. The user hides done items and swipes to delete:
Tap the line with the bug.
struct TodoListView: View {
@State private var todos: [Todo] = Todo.samples
@State private var showDone = false
private var visible: [Todo] { showDone ? todos : todos.filter { !$0.isDone } }
var body: some View {
List {
ForEach(visible) { todo in Text(todo.title) }
.onDelete { offsets in todos.remove(atOffsets: offsets) }
}
}
}Hint: todos is [Buy milk (done), Call mom, Pay rent]. With done items hidden, the user deletes the first visible row. Which todo is removed?
onDelete gives you positions in the list on screen, which is visible, the filtered array. The code applies them to todos, the full array. Deleting the first visible row (“Call mom”, position 0 in visible) removes position 0 of todos: “Buy milk”. The wrong item disappears, and it only happens when a filter or search is active, so it slips through testing.
Map the positions to ids using the same array the user saw:
.onDelete { offsets in
let ids = Set(offsets.map { visible[$0].id })
todos.removeAll { ids.contains($0.id) }
}List identity and performance
- Identity drives everything: diffing, animations, row state, swipe actions and scroll position all key off
id. Use the server’s id; neverUUID()in a computed property, and avoidid: \.selfon values that can repeat. Listis cell-reuse backed and handles very long lists;ScrollView+LazyVStackgives more layout control but no reuse (views are created lazily and kept), which matters for thousands of complex rows.- Keep row views small with explicit inputs, so SwiftUI can skip rows whose inputs didn’t change, and load images asynchronously with a cache instead of in
body. - Large data changes (a filter on 10,000 items) are dominated by the diff; precompute filtered arrays in the model rather than in
body, and make sureids are cheap to hash.
| Need | API |
|---|---|
| Rows with identity | List(items) / ForEach with Identifiable (server ids, never UUID() per read) |
| Push on tap | NavigationLink(value:) + one .navigationDestination(for:) per type, outside lazy containers |
| Programmatic navigation | NavigationStack(path: $path), append, removeLast, removeAll |
| Mixed route types | NavigationPath; .codable is nil if any element isn’t Codable |
| Edit | .swipeActions, .onDelete (offsets are into the displayed collection), .onMove, EditButton |
| Filter | .searchable(text:), filter in the model, not in body |
| iPad / Mac | NavigationSplitView with selection bindings |
| Deep links / restoration | onOpenURL → route stack, SceneStorage with a Codable snapshot |
Gotchas: destinations inside List rows, two destinations for the same type (use route enums, never String), delete offsets applied to the wrong array, models instead of ids in the path.
Check this part
Why should items in a List be Identifiable?
Where should .navigationDestination(for:) go?
onDelete on a filtered list gives offsets [0]. What do they refer to?
A row model uses
var id: UUID { UUID() }. What goes wrong?