SwiftUI for Beginners ยท Lesson 5 of 9
Navigation & Multiple Screens
So far, everything we've built has lived on a single screen.
In Lesson 4, we created a list of data:
My Tasks โ Learn SwiftUI โ Build a small app โ Practice every day
But real apps rarely have only one screen.
Think about apps you already use:
Messages โ Conversation Products โ Product Details Contacts โ Contact Details Settings โ Account Settings
A very common pattern is:
LIST โ SELECT AN ITEM โ DETAIL SCREEN
In this lesson, we'll learn how SwiftUI handles navigation using:
NavigationStack โ NavigationLink โ Destination โ Passing Data
By the end, we'll turn our task list into a small multi-screen app:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ My Tasks โ
โ โ
โ โ Learn SwiftUI > โ
โ โ
โ โ Build a small app > โ
โ โ
โ โ Practice every day > โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Tap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ < My Tasks โ
โ โ
โ Task Details โ
โ โ
โ Learn SwiftUI โ
โ โ
โ Completed โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The important idea is:
Navigation lets the user move between screens while carrying the data needed by the next screen.
1. Why Do Apps Need Multiple Screens?
Imagine putting an entire app on one screen:
Tasks Task Details Profile Settings Search Notifications Help
That would quickly become overwhelming.
Instead, we divide the app into smaller screens.
For example:
Home โ โโโ Tasks โ โ โ โโโ Task Details โ โโโ Profile โ โโโ Settings
Each screen has a particular responsibility.
In SwiftUI, each screen is usually just another View.
For example:
struct HomeView: View {
var body: some View {
Text("Home")
}
}
and:
struct DetailView: View {
var body: some View {
Text("Details")
}
}
We already know how to create views.
Now we need a way to move between them.
2. Meet NavigationStack
SwiftUI provides:
NavigationStack
A NavigationStack creates an environment where views can navigate from one screen to another.
Let's start with:
struct HomeView: View {
var body: some View {
NavigationStack {
Text("Home")
}
}
}
Visually, nothing dramatic happens yet.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ Home โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
But now the screen is inside a navigation system.
Think of NavigationStack as the container that manages our navigation history.
NavigationStack
โ
โผ
Home
Later, when we move to another screen:
NavigationStack
โ
โโโ Home
โ
โโโ Details
SwiftUI remembers that Details came after Home.
That allows the user to go back.
3. Adding a Navigation Title
Screens inside a NavigationStack can have navigation titles.
NavigationStack {
Text("Welcome!")
.navigationTitle("Home")
}
Now we get something like:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Home โ โ โ โ Welcome! โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The modifier:
.navigationTitle("Home")
tells the navigation system what title this screen should display.
For our task app, we might write:
.navigationTitle("My Tasks")
4. Meet NavigationLink
Now we need something the user can tap.
SwiftUI provides:
NavigationLink
Here's a simple example:
NavigationStack {
NavigationLink("Show Details") {
DetailView()
}
}
When the user sees:
Show Details >
and taps it, SwiftUI displays:
DetailView()
The flow is:
HomeView โ โ Tap NavigationLink โผ DetailView
Let's create the destination:
struct DetailView: View {
var body: some View {
Text("This is the detail screen")
.navigationTitle("Details")
}
}
Now we have two screens.
5. Our First Multi-Screen App
Let's put everything together.
struct HomeView: View {
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Text("Welcome")
.font(.largeTitle)
.fontWeight(.bold)
NavigationLink("Open Details") {
DetailView()
}
}
.navigationTitle("Home")
}
}
}
And:
struct DetailView: View {
var body: some View {
VStack(spacing: 20) {
Text("Details")
.font(.largeTitle)
.fontWeight(.bold)
Text("You navigated to another screen!")
}
.navigationTitle("Details")
}
}
The user experience becomes:
HOME
Welcome
[ Open Details ]
โ
โ tap
โผ
DETAILS
You navigated to another screen!
SwiftUI also gives us a Back button automatically.
< Home Details
We didn't have to create it ourselves.
Because the screens are inside a NavigationStack, SwiftUI understands the navigation history.
6. How the Navigation Stack Works
The word stack is important.
Imagine a stack of cards.
Initially:
โโโโโโโโโโโโโโโ โ Home โ โโโโโโโโโโโโโโโ
Navigate to Details:
โโโโโโโโโโโโโโโ โ Details โ โโโโโโโโโโโโโโโค โ Home โ โโโโโโโโโโโโโโโ
Details is now on top.
Navigate again:
โโโโโโโโโโโโโโโ โ More Info โ โโโโโโโโโโโโโโโค โ Details โ โโโโโโโโโโโโโโโค โ Home โ โโโโโโโโโโโโโโโ
When the user taps Back:
More Info
โ removed
Details becomes visible again
Back again:
Details โ removed Home becomes visible again
Conceptually:
PUSH
โ
Home โ Details โ More Info
โ
POP
Home โ Details โ More Info
You don't need to memorize the words push and pop right now.
The important idea is:
NavigationStackremembers the screens the user moved through.
7. Navigation Becomes More Useful With Data
Opening a generic Detail screen is useful for learning navigation.
But real apps usually need something more.
Imagine our task list:
Learn SwiftUI Build a small app Practice every day
If the user taps:
Learn SwiftUI
the detail screen should show:
Learn SwiftUI
If they tap:
Practice every day
the detail screen should show:
Practice every day
We don't want to create:
LearnSwiftUIDetailView BuildSmallAppDetailView PracticeEveryDayDetailView
Instead, we'll create one reusable detail screen and pass the selected task into it.
8. Bringing Back Our Task Model
From Lesson 4, we had a model like this:
struct Task: Identifiable {
let id = UUID()
let title: String
let isCompleted: Bool
}
And some data:
let tasks = [
Task(
title: "Learn SwiftUI",
isCompleted: true
),
Task(
title: "Build a small app",
isCompleted: false
),
Task(
title: "Practice every day",
isCompleted: false
)
]
Remember the relationship:
Task โ represents one item tasks โ contains many Task values
Now we're going to use the same data for navigation.
9. Creating a Task Detail Screen
Let's create:
struct TaskDetailView: View {
let task: Task
var body: some View {
VStack(spacing: 20) {
Image(
systemName: task.isCompleted
? "checkmark.circle.fill"
: "circle"
)
.font(.system(size: 60))
Text(task.title)
.font(.title)
.fontWeight(.bold)
Text(
task.isCompleted
? "Completed"
: "Not Completed"
)
.foregroundStyle(.secondary)
Spacer()
}
.padding()
.navigationTitle("Task Details")
}
}
The most important line is:
let task: Task
This means:
TaskDetailViewneeds aTaskin order to display itself.
The screen doesn't own a particular task.
It receives one.
10. Passing Data to Another Screen
Suppose we have:
let task = Task(
title: "Learn SwiftUI",
isCompleted: true
)
We can create the detail screen like this:
TaskDetailView(task: task)
We're passing the task into the next view.
Conceptually:
TaskListView
selected Task
โ
โ pass data
โผ
TaskDetailView
Then the detail screen can read:
task.title
and:
task.isCompleted
So the destination isn't hardcoded.
Its UI depends on the task it receives.
11. Combining List and NavigationLink
Now we can connect everything.
struct TaskListView: View {
let tasks = [
Task(
title: "Learn SwiftUI",
isCompleted: true
),
Task(
title: "Build a small app",
isCompleted: false
),
Task(
title: "Practice every day",
isCompleted: false
)
]
var body: some View {
NavigationStack {
List(tasks) { task in
NavigationLink {
TaskDetailView(task: task)
} label: {
TaskRow(task: task)
}
}
.navigationTitle("My Tasks")
}
}
}
Look carefully at this part:
List(tasks) { task in
For every task, SwiftUI creates a row.
Then:
NavigationLink {
TaskDetailView(task: task)
} label: {
TaskRow(task: task)
}
The row becomes tappable.
Tap that row and SwiftUI creates:
TaskDetailView(task: task)
using the exact task that was selected.
12. Follow One Task Through the App
This is worth visualizing.
Our array contains:
Task 1 title: Learn SwiftUI completed: true Task 2 title: Build a small app completed: false Task 3 title: Practice every day completed: false
List creates:
โ Learn SwiftUI > โ Build a small app > โ Practice every day >
Now the user taps:
โ Build a small app >
That row represents:
Task โโโ title: Build a small app โโโ isCompleted: false
SwiftUI passes that value into:
TaskDetailView(task: task)
The detail screen reads the data and displays:
Task Details โ Build a small app Not Completed
The flow is:
tasks โ List โ TaskRow โ User taps a task โ NavigationLink โ Selected Task โ TaskDetailView
This is an extremely common app pattern.
13. One Detail Screen, Many Possible Results
The nice part is that we only created:
TaskDetailView
once.
But it can display many different tasks.
TaskDetailView
โฒ
โ
โโโโโโผโโโโโโโโโโโโโโโ
โ โ โ
โ โ โ
Task 1 Task 2 Task 3
โ โ โ
โผ โผ โผ
Learn SwiftUI Build small app Practice every day
The view structure stays the same.
The data changes.
This should feel familiar from previous lessons.
Lesson 3:
State changes
โ
UI changes
Lesson 4:
Collection changes
โ
Repeated UI changes
Lesson 5:
Selected data
โ
Destination UI
We're gradually building the SwiftUI mental model.
14. Let's Create a Better Task Row
We can keep our row reusable:
struct TaskRow: View {
let task: Task
var body: some View {
HStack(spacing: 12) {
Image(
systemName: task.isCompleted
? "checkmark.circle.fill"
: "circle"
)
Text(task.title)
Spacer()
}
}
}
Notice that TaskRow doesn't know anything about navigation.
It only knows:
Give me a task and I'll display a row.
Then TaskListView decides:
This row should navigate somewhere when tapped.
That's a useful separation.
TaskRow โ How one task looks TaskListView โ How tasks are listed and where tapping goes TaskDetailView โ How one selected task looks on its own screen
Each view has a clear job.
15. The Complete Example
Here's the complete version.
First, our model:
struct Task: Identifiable {
let id = UUID()
let title: String
let isCompleted: Bool
}
Our reusable row:
struct TaskRow: View {
let task: Task
var body: some View {
HStack(spacing: 12) {
Image(
systemName: task.isCompleted
? "checkmark.circle.fill"
: "circle"
)
Text(task.title)
Spacer()
}
}
}
Our detail screen:
struct TaskDetailView: View {
let task: Task
var body: some View {
VStack(spacing: 20) {
Image(
systemName: task.isCompleted
? "checkmark.circle.fill"
: "circle"
)
.font(.system(size: 60))
Text(task.title)
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text(
task.isCompleted
? "Completed"
: "Not Completed"
)
.foregroundStyle(.secondary)
Spacer()
}
.padding()
.navigationTitle("Task Details")
}
}
And our main screen:
struct TaskListView: View {
let tasks = [
Task(
title: "Learn SwiftUI",
isCompleted: true
),
Task(
title: "Build a small app",
isCompleted: false
),
Task(
title: "Practice every day",
isCompleted: false
)
]
var body: some View {
NavigationStack {
List(tasks) { task in
NavigationLink {
TaskDetailView(task: task)
} label: {
TaskRow(task: task)
}
}
.navigationTitle("My Tasks")
}
}
}
Our application now has an actual screen flow:
TaskListView
โ
โ User selects Task
โผ
TaskDetailView
โ
โ Back
โผ
TaskListView
16. Where Should NavigationStack Go?
A common beginner question is:
Should every screen have its own
NavigationStack?
Usually, no.
For this example, the stack starts around the main screen:
NavigationStack {
List {
// ...
}
}
Then the destination is pushed inside that same navigation stack.
NavigationStack
โ
โโโ TaskListView
โ
โโโ TaskDetailView
We generally don't need:
NavigationStack
โ
TaskListView
โ
another NavigationStack
โ
TaskDetailView
Think of one NavigationStack as managing a particular navigation journey.
For now, our simple rule is:
Put the main screen of this navigation flow inside a
NavigationStack, then navigate to destination views from there.
17. Navigation Titles Belong to Screens
Our list screen has:
.navigationTitle("My Tasks")
Our detail screen has:
.navigationTitle("Task Details")
That means each screen describes its own navigation title.
TaskListView
โ
"My Tasks"
TaskDetailView
โ
"Task Details"
This keeps the responsibility close to the screen itself.
18. Passing More Than One Value
A destination doesn't have to receive an entire model.
For example, we could write:
struct ProfileView: View {
let name: String
let age: Int
var body: some View {
VStack {
Text(name)
Text("Age: \(age)")
}
}
}
Then:
ProfileView(
name: "Alex",
age: 28
)
But when several values belong together, a model often makes more sense.
Instead of:
TaskDetailView(
title: task.title,
isCompleted: task.isCompleted
)
we can simply pass:
TaskDetailView(task: task)
Conceptually:
Instead of carrying: title status priority due date notes separately... carry: Task
That's another reason models are so useful.
19. Navigation Is Not the Same as a Sheet
You may eventually see SwiftUI code like:
.sheet(...)
A sheet is another way of presenting content, but it represents a different interaction.
Navigation usually feels like:
List โ Details โ More Details
A sheet usually appears temporarily over the current screen:
Current Screen
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ Sheet โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโ
For example:
Navigation โ Open a product's details Sheet โ Show a temporary Add Item form
We don't need to learn sheets yet.
For this lesson, keep the mental model focused on:
NavigationStack
โ
NavigationLink
โ
Destination
20. Don't Put Everything in One View
Now that we're creating multiple screens, it's tempting to write everything inside one giant view.
For example:
TaskListView โโโ model code โโโ row layout โโโ detail layout โโโ list layout โโโ navigation โโโ everything else
It may work initially, but it becomes harder to understand.
Our current structure is much clearer:
Task โ Data model TaskRow โ One row TaskListView โ List screen TaskDetailView โ Detail screen
This is one of the most useful habits you can develop early:
Break a screen into views that have clear responsibilities.
That doesn't mean every Text needs its own view.
It means meaningful pieces of UI can become reusable components.
21. A Common Beginner Mistake
Imagine we have:
NavigationLink("Learn SwiftUI") {
TaskDetailView(
task: Task(
title: "Learn SwiftUI",
isCompleted: true
)
)
}
Then:
NavigationLink("Build a small app") {
TaskDetailView(
task: Task(
title: "Build a small app",
isCompleted: false
)
)
}
Then another one.
This works, but we've gone back to hardcoding our data into the UI.
Lesson 4 taught us a better approach:
List(tasks) { task in
and then:
NavigationLink {
TaskDetailView(task: task)
} label: {
TaskRow(task: task)
}
Now the same structure works for:
3 tasks 30 tasks 300 tasks
The navigation is being created from the data.
22. Data-Driven Navigation
Let's look at the bigger idea.
Our data is:
tasks
That data creates:
rows
Each row creates a navigation opportunity:
row โ NavigationLink
And the selected data creates the destination:
selected task
โ
TaskDetailView
So the entire flow is driven by data:
tasks
โ
โผ
List
โ
โโโโโโโโโโผโโโโโโโโโ
โผ โผ โผ
Task Task Task
โ โ โ
โผ โผ โผ
Row Row Row
โ โ โ
โโโโโโโโโโผโโโโโโโโโโ
โ
User taps
โ
โผ
Selected Task
โ
โผ
TaskDetailView
That's a much more powerful mental model than thinking:
Button 1 opens screen 1, Button 2 opens screen 2...
23. Lesson 5 Mental Model
If you remember one flow from this lesson, remember:
โโโโโโโโโโโโโโโโโโโ
โ NavigationStack โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ List Screen โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โ NavigationLink
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ Selected Data โ
โโโโโโโโโโฌโโโโโโโโโ
โ
โ pass data
โผ
โโโโโโโโโโโโโโโโโโโ
โ Detail Screen โ
โโโโโโโโโโโโโโโโโโโ
Or more simply:
SCREEN โ USER SELECTS SOMETHING โ PASS THE SELECTED DATA โ NEXT SCREEN
And SwiftUI's basic tools are:
NavigationStack
โ
NavigationLink
โ
Destination View
Your Challenge
Build a small Recipe Browser.
Start with a model:
struct Recipe: Identifiable {
let id = UUID()
let name: String
let time: Int
let difficulty: String
}
Create several recipes:
let recipes = [
Recipe(
name: "Pasta",
time: 25,
difficulty: "Easy"
),
Recipe(
name: "Vegetable Curry",
time: 40,
difficulty: "Medium"
),
Recipe(
name: "Pancakes",
time: 20,
difficulty: "Easy"
)
]
Your first screen should look roughly like:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ Recipes โ โ โ โ ๐ Pasta > โ โ 25 minutes โ โ โ โ ๐ Vegetable Curry > โ โ 40 minutes โ โ โ โ ๐ฅ Pancakes > โ โ 20 minutes โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
When the user taps:
Vegetable Curry
navigate to:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ < Recipes Recipe Details โ โ โ โ Vegetable Curry โ โ โ โ 40 minutes โ โ โ โ Medium โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Requirements:
- Create a
Recipemodel. - Make it conform to
Identifiable. - Store multiple recipes in an array.
- Put the main screen inside a
NavigationStack. - Display the recipes using
List. - Make each recipe navigable using
NavigationLink. - Create a separate
RecipeRow. - Create a separate
RecipeDetailView. - Pass the selected
Recipeinto the detail screen. - Display its name, cooking time, and difficulty.
Bonus challenge
Add another property:
let isFavorite: Bool
Show:
โฅ Favourite
on the detail screen when it's true.
Otherwise show:
โก Not Favourite
Don't make the favourite status interactive yet.
The goal is still:
Pass data from one screen to another and let that data determine the destination UI.
What You Now Know
You can now build a basic multi-screen SwiftUI application.
You've learned:
NavigationStack
NavigationLink
.navigationTitle()
Destination Views
Passing Data Between Views
And you've combined them with concepts from previous lessons:
struct
Identifiable
List
ForEach
Reusable Views
Our course progression now looks like:
Lesson 1
What is a SwiftUI View?
โ
Lesson 2
How do we arrange views?
โ
Lesson 3
How does interaction change state and UI?
โ
Lesson 4
How does data create repeated UI?
โ
Lesson 5
How do we move between screens
and pass selected data?
We've now reached an important point.
Our app can:
Display UI โ Respond to interaction โ Represent collections of data โ Create repeated views โ Navigate between screens โ Pass data forward โ
But there's a new problem.
So far, when a child view receives something like:
let task: Task
it can read that data.
What if the child view needs to change data owned by another view?
That's where our next lesson begins.
Lesson 6 โ Sharing State Between Views
We'll learn how a parent and child view can work with the same state using concepts such as:
@State
@Binding
and build the mental model:
Parent owns the state
โ
Child receives a binding
โ
Child changes the value
โ
Parent and child stay in sync
That's when the @State we learned in Lesson 3 and the reusable views we've been building start working together across multiple views.