22.
Lists & Navigation
Written by Audrey Tam
Most apps have at least one view that displays a collection of similar items in a table or grid. When there are too many items to fit on one screen, the user can view more items by scrolling — vertically, horizontally or both. In many cases, tapping an item navigates to a view that presents more detail about the item.
In this section, you’ll start implementing TheMet, an app that searches The Metropolitan Museum of Art, New York for objects matching the user’s query term.
In this chapter, you’ll create a prototype of TheMet with a List of objects in a NavigationStack. Tapping a list item pushes a detail view onto the navigation stack. The starter project already contains ObjectView.swift, which displays some of the object properties.
Getting Started
➤ Open the TheMet app in the starter folder. For this chapter, the starter project initializes the Object data in Preview Content. In Chapter 24, “Downloading Data”, you’ll fetch this data from collectionapi.metmuseum.org.
List
You encountered the SwiftUI List view in Chapter 10, “Working With Datasets”, where you learned how to let users edit the history of exercises in HIITFit.
List is the easiest way to present a collection of items in a view that scrolls vertically. You can display individual views and loop over arrays within the same List. In this chapter, you’ll start by just listing objects, then you’ll embed the list in a navigation stack so users can navigate to a detail view for each list item.
To present a list of objects, the syntax looks a lot like ForEach.
➤ In ContentView.swift, replace the contents of ContentView with the following code:
@StateObject private var store = TheMetStore()
var body: some View {
List(store.objects, id: \.objectID) { object in
Text(object.title)
}
}
You initialize TheMetStore, which calls createDevData() to create a sample objects array. Then, you tell List to loop over objects, and you provide an id. Like ForEach, List expects each item to have an identifier, so it knows which item is in which row. The argument \.objectID tells List that each item is identified by that property value. For each object in the list, you display its title.
In Live Preview, it doesn’t look like much but, later in this chapter, you’ll spruce it up with a title, custom colors and a search button.
NavigationStack
In Chapter 13, “Outlining a Photo Collage App”, you used NavigationStack so you could add toolbar buttons to SingleCardView. Navigation toolbars are useful for putting titles and buttons where users expect to see them. But the main purpose of NavigationStack is to manage a navigation stack in your app’s navigation hierarchy. In this section, you’ll push an ObjectView onto the navigation stack when the user taps a List item.
Start by adding a navigation bar with a title.
➤ In ContentView.swift, replace List with the following to embed it in a NavigationStack and set the screen’s title:
NavigationStack {
List(store.objects, id: \.objectID) { object in
Text(object.title)
}
.navigationTitle("The Met")
}
Notice navigationTitle modifies List, not NavigationStack. A NavigationStack can contain alternative root views, each with its own .navigationTitle and toolbars.
In Live Preview, you get a large title by default:
Navigating to a Detail View
Now, you’ll navigate to the object’s ObjectView when the user taps the list item.
➤ In the List closure, replace Text(object.title) with this:
NavigationLink(object.title) {
ObjectView(object: object)
}
A NavigationLink takes two arguments — a label and a destination. Here, you label the link with the object’s title and, in a trailing closure, set its destination to ObjectView. Each List row acquires a disclosure indicator, telling the user there’s more to see.
Note: When the label is only text, NavigationLink has a convenience initializer that creates a Text view from a String.
➤ In Live Preview, tap an item:
ContentView is currently the only view in the navigation stack. When you tap a list item, NavigationStack pushes ObjectView onto the navigation stack: It’s now the top view on the stack, so it’s the view that’s visible.
NavigationStack gives you a “back” button, labeled the same as the root view’s navigationTitle.
➤ Tap the back button to pop this view off the navigation stack, revealing ContentView again.
Using the Internet
You’ll soon learn how to download data from an internet server into your app, but first you’ll see a couple of ways to use the device’s default browser.
One of the Object properties is objectURL — the URL of the object’s page at metmuseum.org. There’s an easy way to open this page in the device’s default browser.
Link Button
➤ In ContentView.swift, comment out the NavigationLink(...) { ... } code and type the following code:
Link(object.title, destination: URL(string: object.objectURL)!)
You create a special button whose label is the object’s title. Tapping this button opens its destination URL in the associated app. You create the URL from the Object property objectURL. The associated app is Safari (in a simulator) or your device’s default browser.
Note: To be safe, you should check
URL(string: object.objectURL)isn’tnilbut, for now, you can assume metmuseum.org always supplies a valid URL string inobjectURL.
➤ Build and run in the simulator or on your device: There’s no disclosure indicator anymore because the whole list row is a button. Tap an item to open the object’s web page in Safari or your device’s default browser:
Link takes users from your app to their browser app, giving them access to their browser settings and saved passwords. They can easily explore the site without sharing any secure data or history with your app. It’s the normal browser app, so your users can even enter another URL in the location field and go anywhere on the internet.
➤ To return to your app, tap the TheMet back button.
SFSafariViewController
You might prefer your users don’t leave your app. You can open a Safari browser in your app. Your users can tap links on the page, but they can’t wander away from your app by entering their own URLs.
➤ Look at SafariView.swift:
import SwiftUI
import SafariServices
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(
context: UIViewControllerRepresentableContext<SafariView>
) -> SFSafariViewController {
return SFSafariViewController(url: url)
}
func updateUIViewController(
_ uiViewController: SFSafariViewController,
context: UIViewControllerRepresentableContext<SafariView>) {}
}
➤ Option-click SFSafariViewController and read its documentation.
SFSafariViewController is a UIViewController that provides Safari features to your users, but your app cannot access their activity or private information.
You use Representable protocols to insert UIKit views or view controllers into your SwiftUI apps. Instead of creating a structure that conforms to View for a SwiftUI view, you create a structure that conforms to either UIViewRepresentable — for a single view — or UIViewControllerRepresentable — to use a view controller for complex management of views.
UIViewControllerRepresentable requires methods to make and update the view controller. SFSafariViewController needs only a URL and doesn’t really need any updating.
Note: For a more complex example of
UIViewControllerRepresentable, see SwiftUI by Tutorials, Chapter 21, “Complex Interfaces”.
➤ Go back to ContentView.swift and replace the Link(...) { ... } code with the following code:
NavigationLink(
destination: SafariView(url: URL(string: object.objectURL)!)) {
HStack {
Text(object.title)
Spacer()
Image(systemName: "rectangle.portrait.and.arrow.right.fill")
.font(.footnote)
}
}
The destination is a SafariView that loads the object’s web page. This time, you define the label in the trailing closure because it’s more complex than a String — you add an icon to indicate that tapping the item takes the user to a web page.
➤ In Live Preview, tap an item:
Now, NavigationStack pushes SafariView onto the navigation stack and gives you the standard The Met back button. There’s no location field, although there are buttons for the share sheet, and the user can open this page in their default browser.
➤ Tap the back button to pop this view off the navigation stack and return to the list view.
AsyncImage
In the next chapters, you’ll learn how to use URLSession methods to download Object data from metmuseum.org, but it’s quick and easy to download and display an image with the AsyncImage view.
If an object is in the public domain, then its images are available for use without restriction under the Met’s Open Access program, and its primaryImageSmall property is a non-empty string — a web address.
➤ In ObjectView.swift, locate the if object.isPublicDomain closure and replace its contents with this code:
AsyncImage(url: URL(string: object.primaryImageSmall)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
PlaceholderView(note: "Display image here")
}
Leave the else closure as it is.
The url argument is URL? so, if primaryImageSmall yields a valid URL, the view returns an image. You modify this image with the usual image modifiers and reuse the PlaceholderView “picture frame” as a placeholder while the image is downloading.
Note: Bear in mind that you can’t apply modifiers directly to
AsyncImage, instead you need to apply them toimage. There’s more you can do withAsyncImage, like animate the way the image appears or handle possible errors. If you want to learn about it, take a look at AsyncImage’s official documentation.
In Live Preview, the image for “Bahram Gur Slays the Rhino-Wolf” appears:
That was super easy!
navigationDestination
This app can download two kinds of objects from metmuseum.org. Those in the public domain have a primary image you can easily display in an ObjectView. What should your app do for objects that aren’t in the public domain? Well, you can just as easily load their web page into a SafariView. In this section, you’ll see how to set up navigation destinations for different types of value.
Extracting Web Indicator View
➤ First, in ContentView.swift, to keep your code neat, extract the HStack with the web indicator into its own view:
struct WebIndicatorView: View {
let title: String
var body: some View {
HStack {
Text(title)
Spacer()
Image(systemName: "rectangle.portrait.and.arrow.right.fill")
.font(.footnote)
}
}
}
➤ And, your NavigationLink becomes:
NavigationLink(destination: SafariView(url: URL(string: object.objectURL)!)) {
WebIndicatorView(title: object.title)
}
Handling Both Kinds of Objects
➤ Now, comment out the SafariView navigation link and uncomment the ObjectView navigation link:
NavigationLink(object.title) {
ObjectView(object: object)
}
You’ll soon set up the List with both navigation links, but first, see what happens:
➤ In Live Preview, tap the second item — Terracotta oil lamp:
This object isn’t in the public domain, so its primaryImageSmall is an empty string, and ObjectView has no image to display. Displaying this message is … OK, but it’s a better user experience to open the object’s page in SafariView.
Note: Actually, this lamp is in the public domain, but MetStoreDevData.swift creates it as if it isn’t.
Now, here’s one way to display public-domain objects with the object.title label and non-public-domain objects with the WebIndicatorView label.
➤ Uncomment the WebIndicatorView navigation link closure and carefully enclose it and the ObjectView navigation link closure in an if-else:
if !object.isPublicDomain,
let url = URL(string: object.objectURL) {
NavigationLink(destination: SafariView(url: url)) {
WebIndicatorView(title: object.title)
}
} else {
NavigationLink(object.title) {
ObjectView(object: object)
}
}
You send non-public-domain objects to SafariView(url:) and public-domain objects to ObjectView(object:). And, this is a good opportunity to safely unwrap URL(string: object.objectURL) and pass url to SafariView(url:)
➤ In Live Preview, tap a public-domain item, then the oil lamp:
This works fine: The poor rhino-wolf gets slain in ObjectView, and SafariView loads the oil lamp’s web page. But, there’s another NavigationLink initializer you can use with the navigationDestination modifier.
Using navigationDestination
➤ Replace your if-else code with the following:
if !object.isPublicDomain,
let url = URL(string: object.objectURL) {
NavigationLink(value: url) {
WebIndicatorView(title: object.title)
}
} else {
NavigationLink(value: object) {
Text(object.title)
}
}
You use the value initializer for NavigationLink, so both label views are in the trailing closures. This version expects you to modify the enclosing List with a matching navigationDestination for each type of value.
➤ Below .naviationTitle("The Met"), add these List modifiers:
.navigationDestination(for: URL.self) { url in
SafariView(url: url)
.navigationBarTitleDisplayMode(.inline)
.ignoresSafeArea()
}
.navigationDestination(for: Object.self) { object in
ObjectView(object: object)
}
For non-public-domain objects with a valid objectURL, NavigationLink passes url, which is a URL. It matches .navigationDestination(for: URL.self), which receives the value as url, so the destination is still SafariView(url: url). The two modifiers of SafariView reduce the gap below the back button and make the SafariView toolbar color match the app’s navigation toolbar. It simply looks a little nicer.
For public-domain objects or non-public-domain objects without a valid objectURL, NavigationLink passes an object, which matches .navigationDestination(for: Object.self), so the destination is still ObjectView(object: object).
➤ In Live Preview, try both kinds of navigation link to confirm they still work the same.
Testing for Invalid objectURL
Now that you’re checking for a valid URL before calling SafariView(url:), how do you test for an invalid URL? Well, you’ve got your own sample data in MetStoreDevData.swift, and you can set any values you like.
➤ In MetStoreDevData.swift, comment out the objectURL line for the terracotta oil lamp and type this line below it:
objectURL: "", // don't forget the comma!
➤ Back in ContentView.swift, refresh Live Preview to see the oil lamp item no longer uses WebIndicatorView(title:):
➤ Tap the oil lamp item:
The message “Image not in public domain.” is useful, but you could add a little more information to explain why the user sees this view instead of a metmuseum.org web page.
➤ In ObjectView.swift, add “URL not valid.” to the note:
PlaceholderView(note: "Image not in public domain. URL not valid.")
➤ Back in ContentView.swift, refresh Live Preview, then tap the oil lamp item:
That will do — you don’t really expect this situation will happen, but you’ve got it covered anyway.
Using Custom Colors
➤ In MetStoreDevData.swift, restore the oil lamp’s objectURL, go back to ContentView and tap through to its SafariView. Then tap THE MET (on the web page) to go to the home page:
The header has a red background and, scrolling down to Locations and Hours, the sky has several pleasant shades of blue.
In your app, Assets.xcassets defines the red as met-background and one of the blues as met-foreground. ColorExtension.swift extends Color to add metBackground and metForeground as static properties. You’ll use these colors to differentiate the public-domain and non-public-domain rows.
Color-Coding the List Rows
➤ Add these modifiers to the NavigationLink of the non-public-domain objects:
.listRowBackground(Color.metBackground)
.foregroundStyle(.white)
➤ And add this modifier to the NavigationLink of the public-domain objects:
.listRowBackground(Color.metForeground)
You’ve made the non-public-domain rows red, changing the text color to white, so it shows up on the red background. And you made the public-domain rows sky blue.
Linking to Met From ObjectView
Tapping a public-domain object navigates to its ObjectView, which downloads and displays its primary image. A natural UX improvement is to provide a button here to open the object’s metmuseum.org page.
➤ In ObjectView.swift, replace Text(object.title) and its three modifiers with this if-else code:
if let url = URL(string: object.objectURL) {
Link(destination: url) {
WebIndicatorView(title: object.title)
.multilineTextAlignment(.leading)
.font(.callout)
.frame(minHeight: 44)
// add these four modifiers
.padding()
.background(Color.metBackground)
.foregroundColor(.white)
.cornerRadius(10)
}
} else {
Text(object.title)
.multilineTextAlignment(.leading)
.font(.callout)
.frame(minHeight: 44)
}
If the object’s objectURL is valid, you wrap its title in a WebIndicatorView and style it to look like the non-public-domain rows in your List. Then, you create a Link button with this view as its label.
It looks great in Live Preview:
Build and run the app in a simulator to try it out.
One Last Thing
Soon, you’ll implement the code to download objects from metmuseum.org. These objects will match the user’s query term, like “rhino” or “persimmon”. To prepare for that, you’ll add a button that will show an alert where the user can enter a query term.
➤ In ContentView.swift, add these two @State properties:
@State private var query = "rhino"
@State private var showQueryField = false
You provide a starting query term and initialize the value that shows or hides the alert.
➤ Next, below .navigationTitle("The Met"), add the toolbar button:
.toolbar {
Button("Search the Met") {
query = ""
showQueryField = true
}
.foregroundColor(Color.metBackground)
.padding(.horizontal)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.metBackground, lineWidth: 2))
}
When the user taps this button, it resets query to the empty string and sets showQueryField to show the alert. You set the color of the button’s text and border to metBackground to make it stand out.
➤ Now, below the toolbar modifier, add the alert modifier:
.alert("Search the Met", isPresented: $showQueryField) {
TextField("Search the Met", text: $query)
Button("Search") { }
}
You pass a binding to query to the text field. You’ll fill in the Search button’s action after you write the download code in Chapter 24, “Downloading Data”.
➤ In Live Preview, tap the toolbar button:
The Very Last Thing
Users expect to see a reminder of what they searched for, so you’ll add a message above the list.
➤ First, embed List in a VStack, then add this code at the top of the VStack:
Text("You searched for '\(query)'")
.padding(5)
.background(Color.metForeground)
.cornerRadius(10)
➤ In Live Preview, tap the search button, then type some text:
The message updates to You searched for ‘’, then it shows whatever you typed into the text field. Looking good! Now, you’re all set to learn how to download data from a server, after the next chapter, which covers some HTTP and REST API basics.
Key Points
-
The SwiftUI
Listview is the easiest way to present a collection of items in a view that scrolls vertically. Call.listRowBackgroundon the view in the row, not on theListitself. -
NavigationStackmanages a navigation stack in your app’s navigation hierarchy. Tapping aNavigationLinkpushes its destination view onto the navigation stack. Tapping the back button pops this view off the navigation stack. -
A
NavigationStackcan contain alternative root views. You modify each with its ownnavigationTitleand toolbars. -
A
NavigationLinkhas an initializer that takes two arguments — a label view and a destination view. You can supply aStringfor the label, andNavigationLinkwill create aTextview. -
You can navigate using values thanks to the initializer
NavigationLink.init(value:label:). -
Your app can open a web link in the device’s default browser using
Linkor as a Safari view within your app. You can useUIViewControllerRepresentableto insert the UIKit view controllerSFSafariViewControllerin your SwiftUI app. -
It’s easy to download an image and display it with the
AsyncImageview.