SwiftUI Lists and Navigation

Show collections with List and ForEach, move between screens with NavigationStack and NavigationLink, present sheets and alerts, add swipe actions and search, and organise apps with TabView.

Lesson 24 of 43Advanced17 min read

Most apps are made of lists and the screens behind them. Mail shows a list of messages that open into a message; Settings is a list of lists; a shop lists products that open into details. SwiftUI's List builds these scrolling, platform-styled lists from your data in a few lines, and NavigationStack handles moving between screens, complete with the back button and swipe-to-go-back gesture users expect.

Lists depend on the protocols and state you've already learned. Each item needs a stable identity, provided by Identifiable, so SwiftUI can animate insertions and deletions correctly. Editing a list means changing an array held in @State or an @Observable model, and the list updates itself.

In this lesson you'll build a list from data, add sections, swipe actions and search, navigate to detail screens, present sheets and alerts, and organise an app into tabs.

List and ForEach

Swift
struct Contact: Identifiable {
    let id = UUID()
    let name: String
}

struct ContactsView: View {
    let contacts = [Contact(name: "Ada"), Contact(name: "Grace")]
    var body: some View {
        List(contacts) { contact in
            Text(contact.name)
        }
    }
}
  • Data passed to List or ForEach must be Identifiable, or you supply an id key path such as id: \.self for simple values.
  • ForEach repeats views without making its own list, so it can be mixed with fixed rows inside List, or used inside stacks.
  • Section("Title") { ... } groups rows with a header.

Sheets, alerts and tabs

Sheets and alerts are driven by state: set a Boolean, and SwiftUI presents them.

Swift
@State private var showingNew = false

Button("New") { showingNew = true }
    .sheet(isPresented: $showingNew) {
        NewContactView()
    }
  • .alert("Delete?", isPresented: $confirming) { Button("Delete", role: .destructive) { ... } } shows an alert.
  • Inside a sheet, @Environment(\.dismiss) private var dismiss and dismiss() close it.
  • TabView holds the app's top-level sections, each with .tabItem { Label("Home", systemImage: "house") }.

SwiftUI Navigation Syntax

Lists and navigation

List(items) { item in Text(item.name) }
List { ForEach(items) { Text($0.name) }.onDelete { items.remove(atOffsets: $0) } }
Section("Today") { ... }
NavigationStack { ... .navigationTitle("Inbox") }
NavigationLink("Open", value: item)
.navigationDestination(for: Item.self) { DetailView(item: $0) }
.sheet(isPresented: $showing) { EditView() }
.alert("Title", isPresented: $alerting) { Button("OK") { } }
.searchable(text: $query)
TabView { HomeView().tabItem { Label("Home", systemImage: "house") } }

SwiftUI Navigation Summary

  • List and ForEach build rows from Identifiable data; Section groups them.
  • onDelete and swipeActions add editing gestures; changes go to the underlying array.
  • NavigationStack with NavigationLink and navigationDestination moves between screens.
  • Sheets and alerts appear when a bound Boolean becomes true; dismiss closes a sheet.
  • searchable adds a search field; TabView organises top-level sections.

SwiftUI Navigation Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A task list with delete and add

Swift
import SwiftUI

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var done = false
}

struct TasksView: View {
    @State private var tasks = [TodoItem(title: "Buy milk"), TodoItem(title: "Call Sam")]
    @State private var newTitle = ""

    var body: some View {
        List {
            Section("Add") {
                TextField("New task", text: $newTitle)
                    .onSubmit {
                        tasks.append(TodoItem(title: newTitle))
                        newTitle = ""
                    }
            }
            Section("To do (\(tasks.count))") {
                ForEach($tasks) { $task in
                    Toggle(task.title, isOn: $task.done)
                }
                .onDelete { tasks.remove(atOffsets: $0) }
            }
        }
    }
}
Output
[Preview: an "Add" section with a text field, then "To do (2)" with toggles for "Buy milk" and "Call Sam"; swiping a row left reveals Delete]
Line-by-line explanation
  1. Lines 3 to 7Each task has a stable UUID created once, when the task is made.
  2. Lines 17 to 20Pressing return adds a task; the list updates because tasks is state.
  3. Lines 23 to 25ForEach over $tasks gives each row a binding, so a Toggle can edit that task.
  4. Line 26onDelete adds swipe to delete and removes the rows from the array.
Example 2

Navigation to a detail screen

Swift
import SwiftUI

struct Recipe: Identifiable, Hashable {
    let id: Int
    let name: String
    let minutes: Int
}

struct RecipesView: View {
    let recipes = [Recipe(id: 1, name: "Pancakes", minutes: 20),
                   Recipe(id: 2, name: "Curry", minutes: 45)]

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                NavigationLink(value: recipe) {
                    LabeledContent(recipe.name, value: "\(recipe.minutes) min")
                }
            }
            .navigationTitle("Recipes")
            .navigationDestination(for: Recipe.self) { recipe in
                Text("Cook \(recipe.name) for \(recipe.minutes) minutes")
                    .navigationTitle(recipe.name)
            }
        }
    }
}
Output
[Preview: a "Recipes" screen listing "Pancakes 20 min" and "Curry 45 min"; tapping a row slides in a detail screen with a back button]
Line-by-line explanation
  1. Line 3Recipe is Hashable so it can be pushed as a navigation value.
  2. Lines 16 to 18A NavigationLink with a value and a custom label.
  3. Line 20The title appears in the navigation bar.
  4. Lines 21 to 24navigationDestination says which screen to show for a Recipe.
Example 3

Search, a sheet and tabs

Swift
import SwiftUI

struct CitiesView: View {
    let cities = ["Berlin", "Lisbon", "London", "Madrid", "Paris"]
    @State private var query = ""
    @State private var showingAbout = false

    var results: [String] {
        query.isEmpty ? cities : cities.filter { $0.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        NavigationStack {
            List(results, id: \.self) { Text($0) }
                .navigationTitle("Cities")
                .searchable(text: $query)
                .toolbar {
                    Button("About") { showingAbout = true }
                }
                .sheet(isPresented: $showingAbout) {
                    Text("Five cities to visit").presentationDetents([.medium])
                }
        }
    }
}

struct AppTabs: View {
    var body: some View {
        TabView {
            CitiesView().tabItem { Label("Cities", systemImage: "building.2") }
            Text("Saved").tabItem { Label("Saved", systemImage: "heart") }
        }
    }
}
Output
[Preview: a tab bar with Cities and Saved; the Cities tab shows a searchable list, and typing "lo" leaves "Lisbon" and "London"]
Line-by-line explanation
  1. Lines 8 to 10A computed property filters the list from state, so results always match the query.
  2. Line 14Strings use id: \.self because they aren't Identifiable.
  3. Line 16searchable adds a search field bound to query.
  4. Lines 20 to 22The sheet appears when showingAbout becomes true; presentationDetents makes it half height.
  5. Lines 29 to 31TabView shows each section with a tab bar label and icon.

Common SwiftUI Navigation Mistakes

Using indices as ids

Don't
ForEach(0..<items.count, id: \.self) { i in Text(items[i].name) }   // breaks after deletions
Do
ForEach(items) { item in Text(item.name) }                          // items are Identifiable

A new id every time body runs

Don't
struct Row: Identifiable {
    var id: UUID { UUID() }      // a different id on every access
    let title: String
}
Do
struct Row: Identifiable {
    let id = UUID()              // created once
    let title: String
}

Navigation links without a stack

Don't
List(items) { NavigationLink($0.name, value: $0) }   // nothing happens on tap
Do
NavigationStack {
    List(items) { NavigationLink($0.name, value: $0) }
        .navigationDestination(for: Item.self) { DetailView(item: $0) }
}

SwiftUI Navigation in Real Projects

Lists and navigation in shipping apps

Almost every iPhone app's main screen is a List or a scrolling stack inside a NavigationStack, under a TabView. Apps add pull to refresh with .refreshable { await reload() }, search with .searchable, and editing with swipe actions. Storing the navigation path in state lets an app open a specific screen from a notification or a link, a feature called deep linking.

SwiftUI Navigation FAQ

What is the difference between List and ForEach in SwiftUI?

List is a scrolling container with the platform's row styling, separators and swipe actions. ForEach just repeats a view for each item and has no appearance of its own. You often put ForEach inside a List to mix generated rows with fixed ones, or inside a stack for a custom layout.

What is the difference between NavigationView and NavigationStack?

NavigationView is the older container, deprecated from iOS 16. NavigationStack replaces it for push-style navigation and supports value-based links and a bindable path for navigating in code. Use NavigationStack in new apps, and NavigationSplitView for multi-column layouts on iPad and Mac.

Why does my List need Identifiable items?

SwiftUI needs to tell rows apart so it can animate insertions and deletions and keep the right row selected. Conforming to Identifiable with a stable id gives each item that identity. For simple values like unique strings, you can pass id: \.self instead.

How do I go back to the root view in SwiftUI?

Bind the NavigationStack to a path array in state, then empty it with path.removeAll(). Every pushed screen is removed and the stack shows its root view again.

How do I dismiss a sheet in SwiftUI?

Inside the sheet, read the dismiss action with @Environment(\.dismiss) private var dismiss and call dismiss(), for example from a Done button. Alternatively, set the Boolean that presented the sheet back to false in the parent.

SwiftUI Navigation Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the struct so it can be shown in a List without an id argument.

struct Song:  {
    let id = UUID()
    let title: String
}
Multiple choiceeasy

Which container provides the navigation bar and back button?

Multiple choicemedium

How is a sheet usually shown?

Predict the outputmedium

cities is ["Berlin", "Lisbon", "London"] and the filter keeps names where $0.localizedCaseInsensitiveContains("ON"). How many remain?

Multiple choicehard

Why does a value used with navigationDestination(for:) need to be Hashable?

SwiftUI Navigation Quiz

5 questions to check what you learned.

  1. What does onDelete add to a ForEach inside a List?

    • Swipe to delete on each row
    • A delete button in the toolbar
    • An alert
    • Nothing visible
  2. How do you close a sheet from inside it?

    • dismissSheet()
    • Read @Environment(\.dismiss) and call dismiss()
    • Set the view to nil
    • Use a NavigationLink
  3. How do you show a list of plain Strings?

    • Strings can't be shown in lists
    • Pass id: \.self, because Strings aren't Identifiable
    • Wrap each in a UUID
    • Convert them to Ints
  4. What does .searchable(text: $query) do?

    • Searches the web
    • Sorts the list
    • Adds a search field bound to query, which you use to filter the data
    • Highlights rows
  5. What is TabView for?

    • Organising an app's top-level sections, switched with a tab bar
    • Showing tables of data
    • Indenting text
    • Pushing detail screens