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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 5 of 6 of this article. Click here to view the first page.

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.

Note: The modifier’s 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!

Note: It’s the same procedure to create an alert, action sheet or popover. You declare the sheet in a modifier — .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])
Note: The argument of 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.