Skip to content
Commitwise
Menu
iOS Development

Lists and navigation

Show data with List and ForEach, navigate with NavigationStack and value-based links, edit lists safely, and drive navigation from state for deep links and restoration.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Most apps are a list and a detail screen, repeated: products and a product page, chats and a conversation, settings and a setting. SwiftUI makes the first version take ten lines. The bugs come later: a row that opens the wrong detail, a swipe-to-delete that removes the wrong item, a deep link that can’t reach the right screen.

This tutorial builds lists and navigation the way that avoids them: stable identity for rows, navigation driven by values, and navigation state you can read and change.

New to some of these? Open what you need, or skip ahead.

Basics:How do SwiftUI views work?

In SwiftUI, a screen is built from views: small structs that conform to View and describe what to show in a body property:

struct Greeting: View {
    let name: String
    var body: some View {
        Text("Hello, \(name)!")
    }
}

You don’t change views after they’re shown. When the data they depend on changes, SwiftUI calls body again and updates the screen to match. Views are combined by nesting (VStack, List) and adjusted with modifiers like .padding() and .font(.title).

Basics:What are @State and the other @ attributes?

Words like @State, @Binding and @Observable are Swift attributes; most of the ones in SwiftUI are property wrappers. A property wrapper adds behavior to a property: @State var count = 0 still looks like a normal variable, but SwiftUI stores its value outside the view and redraws the view when it changes.

The $ prefix ($count) gives a binding: a two-way connection that lets another view, like a Toggle or TextField, read and change the value.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Part Not started

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:

  • List shows rows, one per item in your data. Your items should be Identifiable, so SwiftUI can tell rows apart even when the data changes.
  • NavigationStack is 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 the List, so it’s registered once for the whole stack.
  • Product is Hashable because navigation values must be, and Identifiable because the List needs stable row identity: the id from your data, never a random value.

The destination in the wrong place

Spot the bugRows that sometimes don't open

Tap the line with the bug.

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:

Spot the bugSwipe to delete removes the wrong todo

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

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; never UUID() in a computed property, and avoid id: \.self on values that can repeat.
  • List is cell-reuse backed and handles very long lists; ScrollView + LazyVStack gives 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 sure ids are cheap to hash.
NeedAPI
Rows with identityList(items) / ForEach with Identifiable (server ids, never UUID() per read)
Push on tapNavigationLink(value:) + one .navigationDestination(for:) per type, outside lazy containers
Programmatic navigationNavigationStack(path: $path), append, removeLast, removeAll
Mixed route typesNavigationPath; .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 / MacNavigationSplitView with selection bindings
Deep links / restorationonOpenURL → 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

  1. Why should items in a List be Identifiable?

  2. Where should .navigationDestination(for:) go?

  3. onDelete on a filtered list gives offsets [0]. What do they refer to?

  4. A row model uses var id: UUID { UUID() }. What goes wrong?

List shows rows with stable identity; NavigationLink(value:) says what was tapped, and one navigationDestination per type, outside the List, says what to show. Edit and delete by id, never by position.

Part Not started

NavigationLink pushes screens when the user taps. Sometimes your code needs to navigate: after saving a form, when a push notification is opened, or to jump back to the start. Step by step:

  1. Keep the stack in state: @State private var path: [Product] = [].
  2. Bind it: NavigationStack(path: $path). The stack now shows one screen per value in path, and taps append to it.
  3. Push from code by appending: path.append(product).
  4. Go back by removing: path.removeLast() for one screen, path.removeAll() to return to the root.

Predict the output

Three detail screens are open (mug, lamp, chair). The code runs path.removeLast(2). What is visible?

struct CatalogView: View {
    let products: [Product]
    @State private var path: [Product] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(products) { product in
                NavigationLink(product.name, value: product)
            }
            .navigationDestination(for: Product.self) { ProductDetailView(product: $0) }
            .toolbar {
                Button("Demo") {
                    path = [mug, lamp, chair]     // three screens pushed at once
                    path.removeLast(2)
                }
            }
        }
    }
}
Exerciseeasy

A contacts list

Build a contacts screen:

  • contacts are grouped into sections by the first letter of their name, sorted alphabetically,
  • a search field filters by name,
  • tapping a contact opens a detail screen with their name, phone and email,
  • a toolbar button jumps to a random contact’s detail from code (to practice programmatic navigation).
›

Dictionary(grouping: contacts) { $0.name.prefix(1).uppercased() } groups contacts by first letter. Sort the keys for the section order.

›

Use NavigationStack(path: $path) with path: [Contact], one .navigationDestination(for: Contact.self), and path.append(contact) in the toolbar button.

SolutionTry it yourself first
struct Contact: Identifiable, Hashable {
    let id: UUID
    let name: String
    let phone: String
    let email: String
}

struct ContactsView: View {
    let contacts: [Contact]
    @State private var query = ""
    @State private var path: [Contact] = []

    private var sections: [(letter: String, contacts: [Contact])] {
        let matching = query.isEmpty
            ? contacts
            : contacts.filter { $0.name.localizedStandardContains(query) }
        let grouped = Dictionary(grouping: matching) { String($0.name.prefix(1)).uppercased() }
        return grouped.keys.sorted().map { letter in
            (letter, grouped[letter]!.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending })
        }
    }

    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(sections, id: \.letter) { section in
                    Section(section.letter) {
                        ForEach(section.contacts) { contact in
                            NavigationLink(contact.name, value: contact)
                        }
                    }
                }
            }
            .searchable(text: $query, prompt: "Search contacts")
            .navigationTitle("Contacts")
            .navigationDestination(for: Contact.self) { ContactDetailView(contact: $0) }
            .toolbar {
                Button("Surprise me", systemImage: "shuffle") {
                    if let contact = contacts.randomElement() { path.append(contact) }
                }
            }
        }
    }
}

struct ContactDetailView: View {
    let contact: Contact

    var body: some View {
        Form {
            LabeledContent("Phone", value: contact.phone)
            LabeledContent("Email", value: contact.email)
        }
        .navigationTitle(contact.name)
    }
}

The section key is a String letter, so ForEach(sections, id: \.letter) has a stable identity per section; each contact has its own id from the data. Navigation works the same whether the user taps a row or the code appends to path: both just add a value to the stack.

Navigating by model types (Product, Contact) works for small apps. At scale, define routes: an enum of every destination, carrying identifiers rather than full models:

enum Route: Hashable, Codable {
    case product(id: Product.ID)
    case cart
    case orderDetail(id: Order.ID)
    case settings(SettingsRoute)
}
  • One destination per route type, registered at the root of each stack, mapping each case to a screen that loads its own data by id. Screens don’t need the previous screen’s model, so deep links can open them directly.
  • A router (@Observable class holding [Route] per tab) owns the paths; features call router.push(.product(id:)) instead of knowing about each other, which is the SwiftUI shape of the Coordinator pattern.
  • Identifiers, not models, in the path: the path must be Hashable (and Codable for restoration), and a stale model in the path shows stale data after an update.
  • NavigationPath (type-erased) lets independent features push their own route types into one stack, at the cost of losing typed access and, as the puzzle shows, possibly restorability.

Restoring the path

Predict the output

What gets printed?

struct ProductRoute: Hashable {
    let id: Int
}

var path = NavigationPath()
path.append(42)                       // Int is Codable
path.append(ProductRoute(id: 7))      // Hashable, but not Codable
print(path.codable == nil)

Two features, one value type

Spot the bugTapping a tag opens a profile

Tap the line with the bug.

NavigationStack(path: $path) {
    HomeFeed()
        .navigationDestination(for: String.self) { username in ProfileView(username: username) }
        .navigationDestination(for: String.self) { tag in TagFeedView(tag: tag) }
}

// In HomeFeed:
NavigationLink("@ana", value: "ana")        // meant for ProfileView
NavigationLink("#swiftui", value: "swiftui") // meant for TagFeedView

Exercisemedium

Deep links and state restoration

A shop app has three tabs (Home, Search, Account), each with its own navigation stack. Product managers want:

  1. links like shop://product/42 and https://shop.example.com/orders/1001 to open the right tab and screen, with a sensible back stack,
  2. the app to reopen on the same screens after the system kills it in the background,
  3. features to push screens without importing each other.

Design the routing layer.

›

Make every destination a Codable route with ids. Then a deep link is “parse URL → tab + [Route]”, and restoration is “encode/decode the paths”.

›

An @Observable router owned at the app root, injected into the environment, holds the selected tab and one path per tab. onOpenURL and SceneStorage both go through it.

SolutionTry it yourself first
enum Tab: String, Codable, Hashable { case home, search, account }

enum Route: Hashable, Codable {
    case product(id: Int)
    case category(slug: String)
    case orders
    case order(id: Int)
}

@MainActor @Observable
final class Router {
    var tab: Tab = .home
    var paths: [Tab: [Route]] = [.home: [], .search: [], .account: []]

    func push(_ route: Route) { paths[tab, default: []].append(route) }

    func open(_ url: URL) {
        guard let (tab, stack) = Self.parse(url) else { return }   // unknown links: ignore, log
        self.tab = tab
        paths[tab] = stack
    }

    /// shop://product/42, https://shop.example.com/orders/1001
    static func parse(_ url: URL) -> (Tab, [Route])? {
        let parts = url.host() == "shop.example.com" ? Array(url.pathComponents.dropFirst()) : [url.host() ?? ""] + url.pathComponents.dropFirst()
        switch parts.first {
        case "product":
            guard let id = parts.dropFirst().first.flatMap(Int.init) else { return nil }
            return (.home, [.product(id: id)])
        case "orders":
            guard let id = parts.dropFirst().first.flatMap(Int.init) else { return (.account, [.orders]) }
            return (.account, [.orders, .order(id: id)])                // a back stack that makes sense
        default:
            return nil
        }
    }

    // Restoration: encode the selected tab and every path.
    struct Snapshot: Codable { var tab: Tab; var paths: [Tab: [Route]] }
    var snapshot: Data? {
        get { try? JSONEncoder().encode(Snapshot(tab: tab, paths: paths)) }
        set {
            guard let newValue, let s = try? JSONDecoder().decode(Snapshot.self, from: newValue) else { return }
            tab = s.tab
            paths = s.paths
        }
    }
}

struct RootView: View {
    @State private var router = Router()
    @SceneStorage("navigation") private var savedNavigation: Data?

    var body: some View {
        TabView(selection: $router.tab) {
            stack(for: .home) { HomeView() }.tabItem { Label("Home", systemImage: "house") }.tag(Tab.home)
            stack(for: .search) { SearchView() }.tabItem { Label("Search", systemImage: "magnifyingglass") }.tag(Tab.search)
            stack(for: .account) { AccountView() }.tabItem { Label("Account", systemImage: "person") }.tag(Tab.account)
        }
        .environment(router)
        .onOpenURL { router.open($0) }
        .onAppear {
            if let savedNavigation { router.snapshot = savedNavigation }   // restore once
        }
        .onChange(of: router.snapshot) { _, newValue in
            savedNavigation = newValue                                      // save on every change
        }
    }

    private func stack(for tab: Tab, @ViewBuilder root: () -> some View) -> some View {
        NavigationStack(path: Binding(get: { router.paths[tab] ?? [] }, set: { router.paths[tab] = $0 })) {
            root().navigationDestination(for: Route.self) { route in
                switch route {
                case .product(let id):     ProductScreen(id: id)
                case .category(let slug):  CategoryScreen(slug: slug)
                case .orders:              OrdersScreen()
                case .order(let id):       OrderScreen(id: id)
                }
            }
        }
    }
}

Design points:

  • Routes carry ids, and each screen loads its own data, so any screen can be the first one (deep link, restoration) and never shows a stale copy of a model.
  • Deep links build a full back stack ([.orders, .order(id:)]), so Back goes somewhere sensible instead of out of the app. Unknown or malformed links are ignored and logged, never a crash; links from the web need Universal Links (apple-app-site-association) as well.
  • Features depend on Router and Route, not on each other: @Environment(Router.self) and router.push(.product(id:)). That’s the Coordinator pattern without UIKit.
  • Restoration is encoding: the whole navigation state is a small Codable value in SceneStorage, which the system saves per scene, so iPad multi-window restores each window separately.
  • Tests are plain unit tests on Router.parse and on encoding round-trips, no UI needed.

Deep dives

Deep diveWhy NavigationView was replacedInterview favorite

Before iOS 16, NavigationView drove navigation with a NavigationLink(isActive:) binding per link. Popping three screens meant resetting three booleans in the right order, deep links had to set nested flags one after another, and restoring navigation state was mostly guesswork.

NavigationStack replaced the flags with a single path: an array of values. Pushing is append, popping to the root is removeAll, a deep link is “build the array”, and restoration is “encode the array”. That shift, from navigation as view state to navigation as data, is the interview answer for why the API changed.

  • Routes: one Hashable, Codable enum per stack, carrying ids; one destination per route type at the stack root. Never navigate with String or other primitives: destinations are resolved by type.
  • A router owns per-tab paths: deep links are “URL → tab + [Route]” with a sensible back stack, restoration is encoding the paths into SceneStorage. NavigationPath.codable is nil if any element isn’t Codable.
enum Route: Hashable, Codable { case product(id: Int), cart }

NavigationStack(path: $router.path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            switch route {
            case .product(let id): ProductScreen(id: id)
            case .cart:            CartScreen()
            }
        }
}

Check this part

  1. How do you go back to the root screen from code?

  2. The path is [mug, lamp, chair]. Which screen is visible?

  3. Why use a route enum instead of navigating with String values?

  4. Why put ids rather than full models in routes?

With NavigationStack(path:), navigation is an array you append to and remove from in code. At scale, model destinations as Codable route enums carrying ids, owned by a router, so deep links and restoration are just building and encoding that state.

Key takeaways

  • List shows rows from Identifiable data; use real ids from your data.
  • NavigationLink(value:) says what was tapped; one .navigationDestination(for:) per type, outside the List, says what to show.
  • With NavigationStack(path:), navigation is an array you can append to and remove from in code.
  • Delete and edit by id, not by position, especially when the list is filtered or searched.
  • Model navigation as typed, Codable routes carrying ids; one destination per route type at each stack’s root.
  • A router owning per-tab paths decouples features and makes deep links (“URL → tab + stack”) and restoration (encode the paths) straightforward.
  • Never navigate with primitive types; destinations are resolved by type.
  • List correctness and performance follow from identity: stable, cheap ids and filtering done in the model.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.