SwiftUI Tutorial: Navigation
In this tutorial, you’ll use SwiftUI to implement the navigation of a master-detail app. You’ll learn how to implement a navigation stack, a navigation bar button, a context menu and a modal sheet. By Audrey Tam.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SwiftUI Tutorial: Navigation
45 mins
- Getting Started
- SwiftUI Basics in a Nutshell
- Declarative App Development
- Declaring Views
- Creating a Basic List
- The List id Parameter
- Starting Debug Preview
- Navigating to the Detail View
- Creating a Navigation Link
- Revisiting Honolulu Public Artworks
- Creating Unique id Values With UUID()
- Conforming to Identifiable
- Showing More Detail
- Handling Split View
- Declaring Data Dependencies
- Guiding Principles
- Tools for Data Flow
- Adding a Navigation Bar Button
- Reacting to Artwork
- Adding a Context Menu
- Creating a Tab View App
- Displaying a Modal Sheet
- UIViewRepresentable Protocol
- Adding a Button
- Showing a Modal Sheet
- Dismissing the Modal Sheet
- Bonus Section: Eager Evaluation
- Where to Go From Here?
Showing a Modal Sheet
You’re going to show the map as a modal sheet. The way this works in SwiftUI is with a Bool
value, which is a parameter of the modal sheet. SwiftUI displays the modal sheet only when this value is true
.
Here’s what you do: At the top of DetailView
, add this @State
property:
@State private var showMap = false
Again, you’re declaring a data dependency: Changing the value of showMap
triggers the display and the dismissal of the modal sheet. You initialize showMap
to false
, so the map doesn’t appear when DetailView
loads.
Next, in the button’s action
, set showMap
to true
. So your Button
now looks like this:
Button(action: { self.showMap = true }) {
Image(systemName: "mappin.and.ellipse")
}
OK, your button is all ready to go. Now where do you declare the modal sheet? Well, you attach it as a modifier. To any view! You don’t have to attach it to the button, but that’s the most obvious place to put it. So modify your new button:
Button(action: { self.showMap = true }) {
Image(systemName: "mappin.and.ellipse")
}
.sheet(isPresented: $showMap) {
MapView(coordinate: self.artwork.coordinate)
}
You pass a binding to showMap
as the sheet’s isPresented
argument, because its value must be changed to false
, in order to dismiss the sheet. Either the system or the sheet’s view will make this change.
isPresented
parameter is one way to show or hide the sheet. The trigger can also be an optional object. In this case, the modifier’s item
parameter takes a binding to an optional object. The sheet appears when this object becomes non-nil, and goes away when the object becomes nil.
You specify MapView
as the view to present and pass this artwork’s location coordinates as the coordinate
argument.
To test your new button, switch to ContentView.swift, and run Live Preview. Then tap an item to see its DetailView
, and tap the map button:
And there’s the map pin!
.alert
, .actionSheet
or .popover
. To show or hide the sheet, you pass a binding to a Bool
variable as the argument of isPresented
, or to an optional object as the argument of item
. Then you create the Alert
or ActionSheet
with title, message and buttons. A .popover
modifier just needs a view to display.
Dismissing the Modal Sheet
Now, how to dismiss this modal sheet? Normally, on an iPhone, you just swipe down on a modal view to dismiss it. This gesture tells SwiftUI to set the Bool
value to false
, and the modal disappears.
But this MapView
scrolls when you swipe! To be fair, that’s probably what you want it to do, as that’s what your users will expect. So you’ll have to provide a button to manually dismiss the map.
To do this, you’ll wrap MapView
in another view, where you can add a Done
button. While you’re at it, you’ll add a label to show the locationName
of the artwork.
First, create a new SwiftUI View file, and name it LocationMap.swift.
Next, add these properties to LocationMap
:
@Binding var showModal: Bool
var artwork: Artwork
You’ll pass $showMap
to LocationMap
as its showModal
argument. It’s a @Binding
because LocationMap
will change showModal
to false
, and this change must flow back to DetailView
to dismiss the modal sheet.
And you’ll pass the whole artwork
object to LocationMap
, giving it access to the coordinate
and locationName
properties.
Now the preview needs values for showModal
and artwork
, so add these parameters:
LocationMap(showModal: .constant(true), artwork: artData[0])
showModal
must be a binding, not a plain value. You can change any plain value into a binding with .constant()
.
Next, replace body
with the following:
var body: some View {
VStack {
MapView(coordinate: artwork.coordinate)
HStack {
Text(self.artwork.locationName)
Spacer()
Button("Done") { self.showModal = false }
}
.padding()
}
}
The inner HStack
contains the location name and the Done
button. The Spacer
pushes the two views apart.
The VStack
positions the MapView
above the HStack
, which has some padding all around.
Start Live Preview to see how it looks:
Just what you expected it to look like!
Now, back to DetailView.swift: Replace MapView(coordinate: self.artwork.coordinate)
with this line:
LocationMap(showModal: self.$showMap, artwork: self.artwork)
You’re displaying LocationMap
instead of MapView
, and passing a binding to showMap
and the artwork
object.
Now Live-Preview ContentView
again, tap an item, then tap the map button.
And tap Done to dismiss the map. Well done!
Bonus Section: Eager Evaluation
A curious thing happens when a SwiftUI app starts up: It initializes every object that appears in ContentView
. For example, it initializes DetailView
before the user taps anything that navigates to that view. It initializes every item in List
, whether or not the item is visible in the window.
This is a form of eager evaluation, and it’s a common strategy for programming languages. Is it a problem? Well, if your app has a very large number of items, and each item downloads a large media file, you might not want your initializer to start the download.
To simulate what’s happening, add an init()
method to Artwork
, so you can include a print
statement:
init(
artist: String,
description: String,
locationName: String,
discipline: String,
title: String,
imageName: String,
coordinate: CLLocationCoordinate2D,
reaction: String
) {
print(">>>>> Downloading \(imageName) <<<<<")
self.artist = artist
self.description = description
self.locationName = locationName
self.discipline = discipline
self.title = title
self.imageName = imageName
self.coordinate = coordinate
self.reaction = reaction
}
Now, in ContentView.swift, start a Debug Preview (Control-click the Live Preview button), and watch the debug console:
>>>>> Downloading 002_200105 <<<<< >>>>> Downloading 19300102 <<<<< >>>>> Downloading 193701 <<<<< >>>>> Downloading 193901-5 <<<<< >>>>> Downloading 195801 <<<<< >>>>> Downloading 198912 <<<<< >>>>> Downloading 196001 <<<<< >>>>> Downloading 193301-2 <<<<< >>>>> Downloading 193101 <<<<< >>>>> Downloading 199909 <<<<< >>>>> Downloading 199103-3 <<<<< >>>>> Downloading 197613-5 <<<<< >>>>> Downloading 199802 <<<<< >>>>> Downloading 198803 <<<<< >>>>> Downloading 199303-2 <<<<< >>>>> Downloading 19350202a <<<<< >>>>> Downloading 200304 <<<<<
No surprise, it initialized all of the Artwork
items. If there were 1000 items, and each downloaded a large image or video file, this could be a problem for a mobile app.
Here's a possible solution: Move the download activity to a helper method, and call this method only when the item appears on the screen.
In Artwork.swift, comment out init()
and add this method:
func load() {
print(">>>>> Downloading \(self.imageName) <<<<<")
}
And back in ContentView.swift, modify the List
row:
Text("\(artwork.reaction) \(artwork.title)")
.onAppear() { artwork.load() }
This calls load()
only when the row of this Artwork
is on the screen.
Start a Debug Preview:
<code> >>>>> Downloading 002_200105 <<<<< >>>>> Downloading 19300102 <<<<< >>>>> Downloading 193701 <<<<< >>>>> Downloading 193901-5 <<<<< >>>>> Downloading 195801 <<<<< >>>>> Downloading 198912 <<<<< >>>>> Downloading 196001 <<<<< >>>>> Downloading 193301-2 <<<<< >>>>> Downloading 193101 <<<<< >>>>> Downloading 199909 <<<<< >>>>> Downloading 199103-3 <<<<< >>>>> Downloading 197613-5 <<<<< >>>>> Downloading 199802 <<<<< </code>
This time, the last four items — the ones that aren't visible — haven't "downloaded". Scroll the list to see their message appear in the console.