Skip to content
Educora
Advanced22 min12 / 14

Navigation and lists

Move between screens with NavigationStack and navigationDestination, control the path in code, open sheets, and build editable lists from Identifiable items with ForEach.

Check yourself
In this lesson you will learn
  • Build navigation with NavigationStack, NavigationLink(value:) and navigationDestination(for:)
  • Control the path (path) in code with an array
  • Open a .sheet and close it with dismiss
  • Build a list of Identifiable items that can be deleted and reordered

You open Educora on an iPhone: a list of lessons; tap one and the lesson page slides in from the right, with a “‹ Lessons” button at the top left. An “Add note” button opens a separate window that rises from the bottom. These are the two main navigation styles of iOS: pushing onto a stack and a modal window (sheet). In this lesson you will build both, along with the lists they are based on.

NavigationStack and navigationDestination

**NavigationStack keeps the stack of screens and shows the navigation bar at the top. NavigationLink(value:)** pushes a value (for example, a Lesson) onto the stack, and **navigationDestination(for:)** says which screen to open for a value of that type. The value must be Hashable, and the list needs Identifiable.

Swift
struct Lesson: Identifiable, Hashable {
    let id: Int
    let title: String
    let minutes: Int
}

struct LessonListView: View {
    let lessons: [Lesson]

    var body: some View {
        NavigationStack {
            List(lessons) { lesson in
                NavigationLink(value: lesson) {
                    Label(lesson.title, systemImage: "book")
                }
            }
            .navigationTitle("Lessons")
            .navigationDestination(for: Lesson.self) { lesson in
                LessonDetailView(lesson: lesson)
            }
        }
    }
}
On screen: a large “Lessons” title and rows with arrows on the right; tapping a row opens the lesson page, and swiping from the left edge or tapping “‹ Lessons” goes back.
Old: NavigationView
NavigationView {
    List(lessons) { lesson in
        NavigationLink(destination: LessonDetailView(lesson: lesson)) {
            Text(lesson.title)
        }
    }
    .navigationTitle("Lessons")
}
New: NavigationStack (iOS 16+)
NavigationStack {
    List(lessons) { lesson in
        NavigationLink(lesson.title, value: lesson)
    }
    .navigationTitle("Lessons")
    .navigationDestination(for: Lesson.self) { lesson in
        LessonDetailView(lesson: lesson)
    }
}
NavigationView is deprecated. On the left every row builds its destination screen in advance; on the right the screen is only built when the navigation happens, and navigation can also be controlled in code.

Controlling the path in code

If you keep the stack in an ordinary array, you can change it in code: NavigationStack(path: $path). Appending an element opens a new screen, removing the last one goes back one step, and removeAll() jumps straight to the root screen — for example, back to the list after a quiz ends. For screens of different types, use NavigationPath. You can check how the array behaves without any screen:

Swift
var path: [String] = []
path.append("lesson/7")
path.append("quiz/7")
print(path)

path.removeLast()
print(path.last ?? "root")

path.removeAll()
print(path.isEmpty ? "Back at the list" : "Still deep")
Expected output
["lesson/7", "quiz/7"]
lesson/7
Back at the list
Swift
struct CourseView: View {
    @State private var path: [Lesson] = []
    let lessons: [Lesson]

    var body: some View {
        NavigationStack(path: $path) {
            List(lessons) { lesson in
                NavigationLink(lesson.title, value: lesson)
            }
            .navigationDestination(for: Lesson.self) { lesson in
                LessonDetailView(lesson: lesson)
            }
            .toolbar {
                Button("Continue") {
                    if let next = lessons.first { path.append(next) }
                }
            }
        }
    }
}
The “Continue” button opens the lesson screen from code, without the user tapping a row.

Sheets: modal windows

A sheet is a window that rises from the bottom and covers the current screen; it is meant for short tasks such as writing a note or filling in a form. .sheet(isPresented: $showingNote) is bound to a Bool state: it opens when the value is true. To close itself, the sheet takes the dismiss function from the environment. .presentationDetents([.medium, .large]) can open it at half the screen height.

Swift
struct LessonDetailView: View {
    let lesson: Lesson
    @State private var showingNote = false

    var body: some View {
        Text("\(lesson.minutes) minutes")
            .navigationTitle(lesson.title)
            .toolbar {
                Button("Add note", systemImage: "square.and.pencil") {
                    showingNote = true
                }
            }
            .sheet(isPresented: $showingNote) {
                NoteEditor()
                    .presentationDetents([.medium, .large])
            }
    }
}
A pencil icon on the right of the navigation bar; tapping it raises the note window to half the screen.
Swift
struct NoteEditor: View {
    @Environment(\.dismiss) private var dismiss
    @State private var text = ""

    var body: some View {
        NavigationStack {
            TextEditor(text: $text)
                .padding()
                .navigationTitle("New note")
                .toolbar {
                    Button("Done") { dismiss() }
                }
        }
    }
}
The “Done” button calls dismiss(), and the window slides down and closes.

TabView: tabs at the bottom

If an app has several independent sections — “Lessons”, “Results”, “Profile” — they go into tabs at the bottom of the screen. Each tab keeps its own NavigationStack, so when the user switches tabs, the place they left in each section is remembered. Since iOS 18 tabs are written with the Tab type; apps that also support iOS 17 use the older, now deprecated .tabItem modifier.

Swift
struct RootView: View {
    let lessons: [Lesson]

    var body: some View {
        TabView {
            Tab("Lessons", systemImage: "book") {
                LessonListView(lessons: lessons)
            }
            Tab("Profile", systemImage: "person.crop.circle") {
                NavigationStack {
                    Text("Profile")
                        .navigationTitle("Profile")
                }
            }
        }
    }
}
Two tabs at the bottom of the screen: “Lessons” with a book icon and “Profile” with a person icon; the selected tab is highlighted in the accent colour.

List, ForEach and Identifiable

SwiftUI must recognise every row in a list so that it animates the right row when an element is deleted, added or moved. For this, elements conform to the **Identifiable** protocol: they have a stable, unique id property. ForEach repeats elements inside a List and lets you add .onDelete (swipe to delete) and .onMove (reorder) to them.

Swift
struct EditableLessonsView: View {
    @State private var lessons = [
        Lesson(id: 1, title: "Variables", minutes: 10),
        Lesson(id: 2, title: "Loops", minutes: 12),
        Lesson(id: 3, title: "Functions", minutes: 15)
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(lessons) { lesson in
                    Text(lesson.title)
                }
                .onDelete { offsets in lessons.remove(atOffsets: offsets) }
                .onMove { from, to in lessons.move(fromOffsets: from, toOffset: to) }
            }
            .toolbar { EditButton() }
            .navigationTitle("My lessons")
        }
    }
}
Swiping a row to the left shows a red “Delete” button; tapping “Edit” shows drag handles next to the rows.
  1. 1
    Wrap the preview in a NavigationStack

    To see an inner screen's title and buttons on the canvas, write the preview like this: #Preview { NavigationStack { LessonDetailView(lesson: Lesson(id: 1, title: "Loops", minutes: 12)) } }.

  2. 2
    Check in the simulator

    Run with Cmd+R, tap a row, then drag with the mouse from the left edge to the right to go back — the gesture works automatically.

  3. 3
    Inspect the view hierarchy

    While the app runs, press Debug View Hierarchy in the debug bar: you will see the screen's layers in 3D — useful for finding hidden or overlapping elements.

go to the Home Screen in the simulatorCmd+Shift+H
show or hide the on-screen keyboard in the simulatorCmd+K
save a screenshot in the simulatorCmd+S
go back to the previously opened file in XcodeCtrl+Cmd+Left

Key points

  • NavigationStack keeps the stack of screens; NavigationLink(value:) pushes a value, and navigationDestination(for:) chooses the screen for it.
  • With NavigationStack(path: $path) the path is a plain array: append goes forward, removeLast back, removeAll to the root.
  • .sheet(isPresented:) opens a modal window, which closes with @Environment(\.dismiss).
  • Independent sections go into TabView tabs; since iOS 18 tabs are written with the Tab type.
  • List elements should be Identifiable types with a stable id; .onDelete and .onMove are added to ForEach.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What decides which screen opens for a value sent with NavigationLink(value:)?