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?
Getting Started
Use the Download Materials button at the top or bottom of this tutorial to download the starter project. Open the PublicArt project in the PublicArt-Starter folder. You’ll build a master-detail app using the Artwork.swift and MapView.swift files already included in this project.
SwiftUI Basics in a Nutshell
SwiftUI lets you ignore Interface Builder and storyboards without having to write detailed step-by-step instructions for laying out your UI. You can preview a SwiftUI view side-by-side with its code — a change to one side will update the other side, so they’re always in sync. There aren’t any identifier strings to get wrong. And it’s code, but a lot less than you’d write for UIKit, so it’s easier to understand, edit and debug. What’s not to love?
The canvas preview means you don’t need a storyboard. The subviews keep themselves updated, so you don’t need a view controller either. And live preview means you rarely need to launch the simulator.
SwiftUI doesn’t replace UIKit — like Swift and Objective-C, you can use both in the same app. At the end of this tutorial, you’ll see how easy it is to use a UIKit view in a SwiftUI app.
Declarative App Development
SwiftUI enables you to do declarative app development: You declare both how you want the views in your UI to look and also what data they depend on. The SwiftUI framework takes care of creating views when they should appear and updating them whenever there’s a change to data they depend on. It recomputes the view and all its children, then renders what has changed.
A view’s state depends on its data, so you declare the possible states for your view, and how the view appears for each state — how the view reacts to data changes or how data affect the view. Yes, there’s a definite reactive feeling to SwiftUI! So if you’re already using one of the reactive programming frameworks, you’ll probably have an easier time picking up SwiftUI.
Declaring Views
A SwiftUI view is a piece of your UI: You combine small views to build larger views. There are lots of primitive views like Text
and Color
, which you can use as basic building blocks for your custom views.
Open ContentView.swift, and make sure its canvas is open (Option-Command-Return). Then click the + button or press Command-Shift-L to open the Library:
The first tab lists primitive views for layout and control, plus Other Views and Paints. Many of these — especially the control views — are familiar to you as UIKit elements, but some are unique to SwiftUI.
The second tab lists modifiers for layout, effects, text, events and other purposes like presentation, environment and accessibility. A modifier is a method that creates a new view from the existing view. You can chain modifiers like a pipeline to customize any view.
SwiftUI encourages you to create small reusable views, then customize them with modifiers for the specific context where you use them. And don’t worry, SwiftUI collapses the modified view into an efficient data structure, so you get all this convenience with no visible performance hit.
Creating a Basic List
Start by creating a basic list for the master view of your master-detail app. In a UIKit app, this would be a UITableViewController
.
Edit ContentView
to look like this:
struct ContentView: View {
let disciplines = ["statue", "mural", "plaque"]
var body: some View {
List(disciplines, id: \.self) { discipline in
Text(discipline)
}
}
}
You create a static array of strings, and you display them in a List
view, which iterates over the array, displaying whatever you specify for each item. And the result looks just like a UITableView
!
Make sure your canvas is open, then refresh the preview (click Resume or press Option-Command-P):
And there’s your list, just like you expected to see. How easy was that? No UITableViewDataSource
methods to implement, no UITableViewCell
to configure, and no UITableViewCell
identifier to misspell in tableView(_:cellForRowAt:)
!
The List id
Parameter
The parameters of List
are the array, which is obvious, and id
, which is less obvious. List
expects each item to have an identifier, so it knows how many unique items there are (instead of tableView(_:numberOfRowsInSection:)
). The argument \.self
tells List
that each item is identified by itself. This is allowed, as long as the item’s type conforms to the Hashable
protocol, which all the built-in types do.
Now take a closer look at how id
works: Add another "statue"
to disciplines
:
let disciplines = ["statue", "mural", "plaque", "statue"]
Refresh the preview: all four items appear. But, according to id: \.self
, there are only three unique items. A breakpoint might shed some light.
Add a breakpoint at Text(discipline)
.
Starting Debug Preview
The Live Preview button is the “play” button near the lower right corner of the canvas device. It runs the view in the canvas, but the ordinary live preview won’t stop at the breakpoint. Right-click or Control-click the Live Preview button, then select Debug Preview from the menu.
The first time you run Debug Preview, it will take a while to load everything. Eventually, execution stops at your breakpoint, and the Variables View displays discipline
:
Click the Continue program execution button: Now discipline = "mural"
.
Click Continue again to see discipline = "plaque"
.
Now, the next time you click the Continue button, what do you think will happen? It’s “statue” again! Surprised? Is this the fourth list item?
Well, click Continue twice more to see “mural” and “plaque” again. Then one final Continue displays the list of four items. So no, execution doesn’t stop for the fourth list item.
What you’ve just seen is: execution visited each of the three unique items twice; “statue” appeared only once on each run-through. So List
does see only three unique items. This isn’t a problem for this simple list of strings, but you’ll soon see an example of a non-unique id
problem.
You’ll also learn a better way to handle the id
parameter. But first, you’ll see how easy it is to navigate to a detail view.
Click the Live Preview button to stop it, and remove the breakpoint.
Navigating to the Detail View
You’ve just seen how easy it is to display the master view. It’s just about as easy to navigate to the detail view.
First, embed List
in a NavigationView
, like this:
NavigationView {
List(disciplines, id: \.self) { discipline in
Text(discipline)
}
.navigationBarTitle("Disciplines")
}
This is like embedding a view controller in a navigation controller: You can now access all the navigation things, like the navigation bar title. Notice .navigationBarTitle
modifies List
, not NavigationView
. You can declare more than one view in a NavigationView
, and each can have its own .navigationBarTitle
.
Refresh the preview to see how this looks:
Nice! You get a large title by default. That’s fine for the master list, but you’ll do something different for the detail view’s title.
Creating a Navigation Link
NavigationView
also enables NavigationLink
, which needs a destination
view and a label — like creating a segue in a storyboard, but without those pesky segue identifiers.
So first, create your DetailView
. For now, just declare it in ContentView.swift, below the ContentView
struct:
struct DetailView: View {
let discipline: String
var body: some View {
Text(discipline)
}
}
This has a single property and, like any Swift struct, it has a default initializer — in this case, DetailView(discipline: String)
. The view is just the String
itself, presented in a Text
view.
Now, inside the List
closure in ContentView
, make the row view Text(discipline)
into a NavigationLink
button:
List(disciplines, id: \.self) { discipline in
NavigationLink(
destination: DetailView(discipline: discipline)) {
Text(discipline)
}
}
There’s no prepare(for:sender:)
rigmarole — you simply pass the current list item to DetailView
to initialize its discipline
property.
Refresh the preview to see a disclosure arrow at the trailing edge of each row:
Start Live Preview, then tap a row to show its detail view:
And zap, it just works! Notice you get the usual back button, too.
But the view looks so plain — it doesn’t even have a title.
So add a title, like this:
var body: some View {
Text(discipline)
.navigationBarTitle(Text(discipline), displayMode: .inline)
}
This view is presented by a NavigationLink
, so it doesn’t need its own NavigationView
to display a navigationBarTitle
. But this version of navigationBarTitle
requires a Text
view for its title
parameter — you’ll get peculiarly meaningless error messages if you try it with just the discipline
string. Option-click the two navigationBarTitle
modifiers to see the difference in the title
and titleKey
parameter types.
The displayMode: .inline
argument displays a normal size title.
Start Live-preview again, and tap a row to see the title:
So now you know how to create a basic master-detail app. You used String
objects, to avoid any clutter that might obscure how lists and navigation work. But list items are usually instances of a model type that you define. It’s time to use some real data.
Revisiting Honolulu Public Artworks
The starter project contains the Artwork.swift file. Artwork
is a struct with eight properties, all constants except for the last, which the user can set:
struct Artwork {
let artist: String
let description: String
let locationName: String
let discipline: String
let title: String
let imageName: String
let coordinate: CLLocationCoordinate2D
var reaction: String
}
Below the struct is artData
, an array of Artwork
objects. It’s a subset of the data used in our MapKit Tutorial: Getting Started — public artworks in Honolulu.
The reaction
property of some of the artData
items is 💕, 🙏 or 🌟 but, for most items, it’s just an empty String
. The idea is when users visit an artwork, they set a reaction to it in the app. So an empty-string reaction
means the user hasn’t visited this artwork yet.
Now start updating your project to use Artwork
and artData
: In ContentView
, add this property:
let artworks = artData
Delete the disciplines
array.
Then replace disciplines
, discipline
and ‘Disciplines’ with artworks
, artwork
and “Artworks”:
List(artworks, id: \.self) { artwork in
NavigationLink(
destination: DetailView(artwork: artwork)) {
Text(artwork.title)
}
}
.navigationBarTitle("Artworks")
And also edit DetailView
to use Artwork
:
struct DetailView: View {
let artwork: Artwork
var body: some View {
Text(artwork.title)
.navigationBarTitle(Text(artwork.title), displayMode: .inline)
}
}
Ah, Artwork
isn’t Hashable
! So change \.self
to \.title
:
List(artworks, id: \.title) { artwork in
You’ll soon create a separate file for DetailView
, but this will do for now.
Now, take another look at that id
parameter in the List
view.
Creating Unique id
Values With UUID()
The argument of the id
parameter can use any combination of the list item’s Hashable
properties. But, like choosing a primary key for a database, it’s easy to get it wrong, then find out the hard way that your identifier isn’t as unique as you thought.
The Artwork title
is unique but, to see what happens if your id
values aren’t unique, replace \.title
with \.discipline
in List
:
List(artworks, id: \.discipline) { artwork in
Refresh the preview (Option-Command-P):
The titles in artData
are all different, but the list thinks all the statues are “Prince Jonah Kuhio Kalanianaole”, all the murals are “The Makahiki Festival Mauka Mural”, and all the plaques are “Amelia Earhart Memorial Plaque”. Each of these is the first item of that discipline that appears in artData
. And this is what can happen if your list items don’t have unique id
values.
Fortunately, the solution is easy — it’s pretty much what many databases do: Add an id
property to your model type, and use UUID()
to generate a unique identifier for every new object.
In Artwork.swift, add this property at the top of the Artwork
property list:
let id = UUID()
You use UUID()
to let the system generate a unique ID value, because you don’t care about the actual value of id
. This unique ID will be very useful later!
Then, in ContentView.swift, change the id
argument in List
to \.id
:
List(artworks, id: \.id) { artwork in
Refresh the preview:
Now each artwork
has a unique id
value, so the list displays everything properly.
Conforming to Identifiable
But there’s an even better way: Go back to Artwork.swift, and add this extension, outside the Artwork
struct:
extension Artwork: Identifiable { }
The id
property is all you need to make Artwork
conform to Identifiable
, and you’ve already added that.
Now you can delete the id
parameter entirely:
List(artworks) { artwork in
Looks much neater now! Because Artwork
conforms to Identifiable
, List
knows it has an id
property and automatically uses this property for its id
argument.
Refresh the preview (Option-Command-P):
And it still works fine.
Showing More Detail
Artwork
objects have lots of information you can display, so update your DetailView
to show more details.
First, create a new SwiftUI View file: Command-N ▸ iOS ▸ User Interface ▸ SwiftUI View. Name it DetailView.swift.
Replace DetailView
in the new file with the DetailView
from ContentView.swift. Be sure to delete it from ContentView.swift.
The preview wants an artwork
argument, so add it:
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(artwork: artData[0])
}
}
Then, add lots of new things to the view:
struct DetailView: View {
let artwork: Artwork
var body: some View {
VStack {
Image(artwork.imageName)
.resizable()
.frame(maxWidth: 300, maxHeight: 600)
.aspectRatio(contentMode: .fit)
Text("\(artwork.reaction) \(artwork.title)")
.font(.headline)
.multilineTextAlignment(.center)
.lineLimit(3)
Text(artwork.locationName)
.font(.subheadline)
Text("Artist: \(artwork.artist)")
.font(.subheadline)
Divider()
Text(artwork.description)
.multilineTextAlignment(.leading)
.lineLimit(20)
}
.padding()
.navigationBarTitle(Text(artwork.title), displayMode: .inline)
}
}
You’re displaying several views in a vertical layout, so everything is in a VStack
.
First is the Image
: The artData
images are all different sizes and aspect ratios, so you specify aspect-fit, and constrain the frame to at most 300 points wide by 600 points high. However, these modifiers won’t take effect unless you first modify the Image
to be resizable
.
You modify the Text
views to specify font size and multilineTextAlignment
, because some of the titles and descriptions are too long for a single line.
Finally, you add some padding around the stack.
Refresh the preview:
And there’s Prince Jonah! In case you’re curious, there are seven syllables in Kalanianaole, four of them in the last six letters ;].
The navigation bar doesn’t appear when you preview or even live-preview DetailView
, because it doesn’t know it’s in a navigation stack.
Go back to ContentView.swift and start Live Preview, then tap a row to see the complete detail view:
Handling Split View
So far, I’ve been showing you previews of the iPhone 8 scheme. But of course, you can view this on an iPad (or even on your Mac, as a Mac Catalyst app).
To see what this looks like on an iPad, select an iPad scheme, then restart the Live Preview:
Um, it’s blank!? Well, it’s an iPad, so SwiftUI shows you a split view. When an iPad is in portrait orientation, you have to swipe from the leading edge to open the master list view, then select an item:
To avoid showing a blank detail view on launch, simply add a specific DetailView
after the List
in ContentView
. Add the following after .navigationBarTitle("Artworks")
:
DetailView(artwork: artworks[0])
Refresh the preview (it doesn’t have to be live):
And now the split view loads with your default detail view.
Change the scheme back to an iPhone to see that this DetailView
doesn’t mess up your master list view!
NavigationView
with .navigationViewStyle(DoubleColumnNavigationViewStyle())
. If you don’t want split view at all, specify StackNavigationViewStyle()
to force the iPhone-style navigation stack behavior.
Declaring Data Dependencies
You’ve seen how easy it is to declare your UI. Now it’s time to learn about the other big feature of SwiftUI: declarative data dependencies.
Guiding Principles
SwiftUI has two guiding principles for managing how data flows through your app:
- Data access = dependency: Reading a piece of data in your view creates a dependency for that data in that view. Every view is a function of its data dependencies — its inputs or state.
- Single source of truth: Every piece of data that a view reads has a source of truth, which is either owned by the view or external to the view. Regardless of where the source of truth lies, you should always have a single source of truth. You give read-write access to a source of truth by passing a binding to it.
In UIKit, the view controller keeps the model and view in sync. In SwiftUI, the declarative view hierarchy plus this single source of truth means you no longer need the view controller.
Tools for Data Flow
SwiftUI provides several tools to help you manage the flow of data in your app.
Property wrappers augment the behavior of variables. SwiftUI-specific wrappers — @State
, @Binding
, @ObservedObject
and @EnvironmentObject
— declare a view’s dependency on the data represented by the variable.
Each wrapper indicates a different source of data:
-
@State
variables are owned by the view.@State var
allocates persistent storage, so you must initialize its value. Apple advises you to mark theseprivate
to emphasize that a@State
variable is owned and managed by that view specifically. -
@Binding
declares dependency on a@State var
owned by another view, which uses the$
prefix to pass a binding to this state variable to another view. In the receiving view,@Binding var
is a reference to the data, so it doesn’t need initialization. This reference enables the view to edit the state of any view that depends on this data. -
@ObservedObject
declares dependency on a reference type that conforms to theObservableObject
protocol: It implements anobjectWillChange
property to publish changes to its data. -
@EnvironmentObject
declares dependency on some shared data — data that’s visible to all views in the app. It’s a convenient way to pass data indirectly, instead of passing data from parent view to child to grandchild, especially if the child view doesn’t need it.
Now move on to practice using @State
and @Binding
for navigation.
Adding a Navigation Bar Button
If an Artwork
has 💕, 🙏 or 🌟 as its reaction
value, it indicates the user has visited this artwork. A useful feature would let users hide their visited artworks, so they can then choose one of the others to visit next.
In this section, you’ll add a button to the navigation bar to show only artworks the user hasn’t yet visited.
Start by displaying the reaction
value in the list row, next to the artwork title: Change Text(artwork.title)
to the following:
Text("\(artwork.reaction) \(artwork.title)")
Refresh the preview to see which items have a non-empty reaction:
Now add these properties at the top of ContentView
:
@State private var hideVisited = false
var showArt: [Artwork] {
hideVisited ? artworks.filter { $0.reaction == "" } : artworks
}
The @State
property wrapper declares a data dependency: Changing the value of this hideVisited
property triggers an update to this view. In this case, changing the value of hideVisited
will hide or show the already-visited artworks. You initialize this to false
, so the list displays all of the artworks when the app launches.
The computed property showArt
is all of artworks
if hideVisited
is false
; otherwise, it’s a sub-array of artworks
, containing only those items in artworks
that have an empty-string reaction
.
Now, replace the first line of the List
declaration with:
List(showArt) { artwork in
Now add a navigationBarItems
modifier to List
after .navigationBarTitle("Artworks")
:
.navigationBarItems(trailing:
Toggle(isOn: $hideVisited, label: { Text("Hide Visited") }))
You’re adding a navigation bar item on the right side (trailing
edge) of the navigation bar. This item is a Toggle
view with label “Hide Visited”.
You pass the binding $hideVisited
to Toggle
. A binding allows read-write access, so Toggle
will be able to change the value of hideVisited
whenever the user taps it. And this change will flow through to update the List
view.
Start Live-Preview to see this working:
Tap the toggle to see the visited artworks disappear: Only the artworks with empty-string reactions remain. Tap again to see the visited artworks reappear.
There’s an alternative to this Toggle
you just implemented: a tab view! You won’t be surprised when I tell you it’s easy to implement a tab view in SwiftUI ;]. You’ll do this as soon as you set up a way for your users to react to an artwork, because it will make the unvisited tab more fun. ;]
Reacting to Artwork
One feature that’s missing from this app is a way for users to set a reaction to an artwork. In this section, you’ll add a context menu to the list row to let users set their reaction for that artwork.
Adding a Context Menu
Still in ContentView.swift, make artworks
a @State
variable:
@State var artworks = artData
The ContentView
struct is immutable, so you need this @State
property wrapper to be able to assign a value to an Artwork
property.
Next, add this helper method stub to ContentView
:
private func setReaction(_ reaction: String, for item: Artwork) { }
Then add the contextMenu
modifier to the list row Text
view:
Text("\(artwork.reaction) \(artwork.title)")
.contextMenu {
Button("Love it: 💕") {
self.setReaction("💕", for: artwork)
}
Button("Thoughtful: 🙏") {
self.setReaction("🙏", for: artwork)
}
Button("Wow!: 🌟") {
self.setReaction("🌟", for: artwork)
}
}
self.
— don’t worry, if you forget, Xcode will tell you and offer to fix it ;].
The context menu shows three buttons, one for each reaction. Each button calls setReaction(_:for:)
with the appropriate emoji.
Finally, implement the setReaction(_:for:)
helper method:
private func setReaction(_ reaction: String, for item: Artwork) {
if let index = self.artworks.firstIndex(
where: { $0.id == item.id }) {
artworks[index].reaction = reaction
}
}
And here’s where the unique ID values do their stuff! You compare id
values to find the index of this item in the artworks
array, then set that array item’s reaction
value.
artwork.reaction = "💕"
directly. Unfortunately, the artwork
list iterator is a let
constant.
Refresh the live preview (Option-Command-P), then touch and hold an item to display the context menu. Tap a context menu button to select a reaction, or tap outside the menu to close it.
How does that make you feel? 💕 🙏 🌟!
Creating a Tab View App
Now you’re ready to construct an alternative app that uses a tab view to list either all artworks or just the unvisited artworks.
Start by creating a new SwiftUI View file to create your alternative master view. Name it ArtTabView.swift.
Next, copy all the code inside ContentView
— not the struct ContentView
line or the closing brace — and paste it inside the struct ArtTabView
closure, replacing the boilerplate body
code.
Now, with the canvas open (Option-Command-Return), Command-click List
, and select Extract Subview from the menu:
Name the new subview ArtList
.
Next, delete the navigationBarItems
toggle. The second tab will replace this feature.
Now add these properties to ArtList
:
@Binding var artworks: [Artwork]
let tabTitle: String
let hideVisited: Bool
You’ll pass a binding to the @State
variable artworks, from ArtTabView
to ArtList
. This is so the context menu will still work.
Each tab will need a navigation bar title. And you’ll use hideVisited
to control which items appear, although this no longer needs to be a @State
variable.
Next, move showArt
and setReaction
from ArtTabView
into ArtList
, to handle these jobs in ArtList
.
Then replace .navigationBarTitle("Artworks")
with:
.navigationBarTitle(tabTitle)
Almost there: In the body
of ArtTabView
, add the necessary parameters to ArtList
:
ArtList(artworks: $artworks, tabTitle: "All Artworks", hideVisited: false)
Refresh the preview to check that this all still works:
Looks good! Now, a TabView
with two tabs by replacing the body
definition for ArtTabView
with:
TabView {
NavigationView {
ArtList(artworks: $artworks, tabTitle: "All Artworks", hideVisited: false)
DetailView(artwork: artworks[0])
}
.tabItem({
Text("Artworks 💕 🙏 🌟")
})
NavigationView {
ArtList(artworks: $artworks, tabTitle: "Unvisited Artworks", hideVisited: true)
DetailView(artwork: artworks[0])
}
.tabItem({ Text("Unvisited Artworks") })
}
The first tab is the unfiltered list, and the second tab is the list of unvisited artworks. The tabItem
modifier specifies the label on each tab.
Start Live-Preview to experience your alternative app:
In the Unvisited Artworks tab, use the context menu to add a reaction to an artwork: it disappears from this list, because it’s no longer unvisited!
let contentView = ContentView()
with let contentView = ArtTabView()
.
Displaying a Modal Sheet
Another feature missing from this app is a map — you want to visit this artwork, but where is it, and how do you get there?
SwiftUI doesn’t have a map primitive view, but there’s one in Apple’s “Interfacing With UIKit” tutorial. I’ve adapted it, adding a pin annotation, and included it in the starter project.
UIViewRepresentable Protocol
Open MapView.swift: It’s A view that hosts an MKMapView
. All the code in makeUIView
and updateUIView
is standard MapKit
stuff. The SwiftUI magic is in the UIViewRepresentable
protocol and its required methods — you guessed it: makeUIView
and updateUIView
. This shows how easy it is to display a UIKit view in a SwiftUI project. It works for any of your custom UIKit views, too.
Now try previewing the MapView
(Option-Command-P). Well, it’s trying to show a map, but it’s just not there. Here’s the trick: You must start Live Preview to see the map:
The preview uses artData[5].coordinate
as sample data, so the map pin shows the location of the Honolulu Zoo Elephant Exhibit, where you can visit the Giraffe sculpture.
Adding a Button
Now head back to DetailView.swift, which needs a button to show the map. You could put one in the navigation bar, but right next to the artwork’s location is also a logical place to put the show-map button.
To place a Button
alongside the Text
view, you need an HStack
. Make sure the canvas is open (Option-Command-Return), then Command-click Text
in this line of code:
Text(artwork.locationName)
And select Embed in HStack from the menu:
Now, to place the button to the left of the location text, you’ll add it before the Text
in the HStack
: Open the Library (Shift-Command-L), and drag a Button
into your code, above Text(artwork.locationName)
.
Button
, hover near Text
until a new line opens above Text
, then release the Button
.
Your code now looks like this:
Button(action: {}) {
Text("Button")
}
Text(artwork.locationName)
.font(.subheadline)
Text("Button")
is the button’s label. Change it to:
Image(systemName: "mappin.and.ellipse")
And refresh the preview:
mappin.circle
and its filled version, but they didn’t appear.
So the label looks right. Now, what should the button’s action
do?
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.
Where to Go From Here?
You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.
In this tutorial, you used SwiftUI to implement the navigation of a master-detail app. You implemented a navigation stack, a navigation bar button, a context menu and a modal sheet, as well as a tab view. And you picked up one technique to prevent too-eager evaluation of your data items.
Apple's WWDC sessions and SwiftUI tutorials are the source of everything, but the API has changed a lot since Xcode 11 beta 1. So you'll find the most up-to-date code in our book SwiftUI by Tutorials.
We hope you enjoyed this tutorial, and if you have any questions or comments, please join the forum discussion below!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more