- Build navigation with
NavigationStack,NavigationLink(value:)andnavigationDestination(for:) - Control the path (
path) in code with an array - Open a
.sheetand close it withdismiss - Build a list of
Identifiableitems 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.
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)
}
}
}
}NavigationView {
List(lessons) { lesson in
NavigationLink(destination: LessonDetailView(lesson: lesson)) {
Text(lesson.title)
}
}
.navigationTitle("Lessons")
}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:
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")["lesson/7", "quiz/7"] lesson/7 Back at the list
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) }
}
}
}
}
}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.
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])
}
}
}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() }
}
}
}
}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.
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")
}
}
}
}
}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.
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")
}
}
}- 1Wrap 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)) } }. - 2Check 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. - 3Inspect the view hierarchy
While the app runs, press
Debug View Hierarchyin the debug bar: you will see the screen's layers in 3D — useful for finding hidden or overlapping elements.
Key points
NavigationStackkeeps the stack of screens;NavigationLink(value:)pushes a value, andnavigationDestination(for:)chooses the screen for it.- With
NavigationStack(path: $path)the path is a plain array:appendgoes forward,removeLastback,removeAllto the root. .sheet(isPresented:)opens a modal window, which closes with@Environment(\.dismiss).- Independent sections go into
TabViewtabs; since iOS 18 tabs are written with theTabtype. - List elements should be
Identifiabletypes with a stableid;.onDeleteand.onMoveare added toForEach.
Check yourself
10 questions. Every correct answer earns XP.
NavigationLink(value:)?