11.
Lists & Navigation
Written by Bill Morefield
It’s a rare app that can work with only a single view; most apps use many views and must provide a way for the user to navigate between them smoothly. The navigation you design has to balance many needs: you need to display data logically to the user, you need to provide an easy way to move between views, and you need to make it easy for the user to figure out how to perform a particular task.
SwiftUI provides a unified interface to manage navigation while also displaying data. In this chapter, you’ll explore how to display data to the user, while also building several types of navigation between views.
Getting started
Open the starter project for this chapter; you’ll find a very early version of an app for an airport. In this chapter, you will build out the app to display today’s flight arrivals and departures. In a real-world app, you would likely get this information from an API through Combine. For this app, though, you’ll be using mock data.
Open the Models folder in the app, and you’ll see two files. The first is FlightInformation.swift, which encapsulates information about flights. It contains a static method generateFlight() that generates test data for one flight, and another static method generateFlights() that generates an array of thirty flights. The other is ContentView.swift, which contains a variable flightInfo in which the app stores a new set of flights each time the app runs.
Navigating through a SwiftUI app
When designing the navigation for your SwiftUI app, you must create a navigation pattern that helps the user to move confidently through the app and intuitively perform tasks. Your users will hardly ever notice navigation that’s done well, but they won’t stand for an app that’s hard to navigate or one that makes it hard to find information. SwiftUI is a cross-platform framework but takes its primary design inspiration from iOS and iPadOS. Therefore, SwiftUI integrates patterns and design guidelines that are common on those platforms.
SwiftUI navigation organizes around two styles: flat and hierarchical. In SwiftUI, you implement a flat hierarchy using a TabView. A flat navigational structure works best when the user needs to move between different views that divide content into categories. The view layout will be broad, with many top-level views. Each view has little depth below. This kind of navigational structure makes it easier for users to discover as the path between the starting view and any view in the app is as short as possible. Too many categories, or indiscernible categories, can overwhelm the user.
Hierarchical navigation provides the user with fewer options at the top, and a deeper structure underneath. In SwiftUI, you implement hierarchical navigation using a NavigationView. Compared to a flat layout, a hierarchical layout has fewer lop level views, but each contains a deeper view stack beneath. The user may also have to backtrack through several layers of the navigation stack to find another view. Hierarchical navigation works well when the user has little need to switch laterally between view stacks, and for view stacks that move from broader to more specific information at each level.
The layout of your views — or view stack — in your app will likely be a combination of these two categories. You might have a top-level using TabView to show several views. Each of those views might then contain a NavigationView that lets the user dive deeper into the app. No matter what your navigation design looks like, your overarching goal should be to keep the navigation consistent within the app. Switching between different navigation paradigms without warning or context can confuse your users.
Creating navigation views
You’ll first set up a tab view in the sample app. Open ContentView.swift and change the body of the view as follows:
// 1
TabView {
// 2
FlightBoard()
// 3
.tabItem({
// 4
Image(systemName: "icloud.and.arrow.down")
.resizable()
Text("Arrivals")
})
FlightBoard()
.tabItem({
Image(systemName: "icloud.and.arrow.up")
.resizable()
Text("Departures")
})
}
Here’s how the tab view code works:
- You first declare that you’re creating a tab view using the
TabViewcontrol. - You provide a set of views to the enclosure of
TabView. Each view becomes the contents of a tab, and modifiers on the views define the information about the tab. - You apply the
tabItem(_:)method to the view contents, for each tab, to set an image, text, or combination of the two. - Each tab displays a system image and a text label. You can only use
Text,Image, or anImagefollowed byTextviews as the tab label. If you use anything else, then the tab will show as visible but empty.
Since the views are identical, it’s a bit hard to see any difference as this uses the default SwiftUI view. You’re going to change that. Open FlightBoard.swift and add the following code above the body of the view:
var boardName: String
You also need to update the preview to provide the expected values. Change the preview to read:
FlightBoard(boardName: "Test")
Also, replace the view with the following code to show the passed in the name parameter.
Text(boardName)
.font(.title)
Go back to ContentView.swift. Change each call to FlightBoard to add the appropriate name. The first one should read:
FlightBoard(boardName: "Arrivals")
And the second call should read:
FlightBoard(boardName: "Departures")
Build and run the app, or open ContentView.swift and start Live Preview, and click on each tab to see the appropriate view.
Many apps work well with the flat navigation style provided by a tab view, and this app presents data that fits well into a master-detail flow. In the next section, you’ll change the app to use a navigation view layout.
Using navigation views
A navigation view arranges multiple views into a stack, transitioning from one view to another. In each view, the user makes a single choice that continues to a new view in the stack. You can go backward in the stack, but you can’t jump between different children in the stack. On a large-screen device, SwiftUI also supports a split-view interface, which separates the main views of the app into separate panes. One view generally remains static, while the second changes as the user navigates through the view stack.
You’ll now change the navigation in your app to a hierarchical style using a NavigationView. You’ll add links to the two flight boards as buttons on the home view. Open ContentView.swift and replace the view body with the following:
// 1
NavigationView {
ZStack {
Image(systemName: "airplane")
.resizable()
.aspectRatio(contentMode: .fit)
.opacity(0.1)
.rotationEffect(.degrees(-90))
.frame(width: 250, height: 250, alignment: .center)
VStack(alignment: .leading, spacing: 5) {
// 2
NavigationLink(destination: FlightBoard(
boardName: "Arrivals")) {
// 3
Text("Arrivals")
}
NavigationLink(destination: FlightBoard(
boardName: "Departures")) {
Text("Departures")
}
Spacer()
}
.font(.title)
.padding(20)
// 4
}
.navigationBarTitle(Text("Mountain Airport"))
}
Here’s how this code sets up the app navigation:
-
NavigationViewdefines the starting point of the stack of views that represent a path in the navigation hierarchy. You’ll usually use this to handle data with a master-detail flow. Here, you start with two broad options that each show a list of items the user can select. The navigation view also provides a toolbar and a link to back out of these views. - This
NavigationLinkstruct creates a button to let the user move deeper into the navigation stack. Thedestination:parameter provides the view to show when the user presses the button to go to the next step in the view stack. - The enclosure for the
NavigationLinkbecomes the view displayed as the link. In this case, you’re using static text. - You use the
navigationBarTitle(_:)method to provide a title for theNavigationViewto display at the top.
It might seem odd that you call navigationBarTitle(_:) on the ZStack and not the NavigationView. But remember, you’re defining a hierarchy of views. A view’s title typically changes when migrating through the view stack. The method finds the navigation view this control resides in and changes that title accordingly.
All methods that change the current navigation view operate on views within the stack — not the stack itself. This also means these settings won’t show on the preview or Live View.
Note: To see the preview in the context, you can wrap the preview inside of a
NavigationView()struct.
The preview shows your progress and the two links.
On the iPhone and Apple TV, SwiftUI uses a navigation stack by default. On larger iPhones, iPads and Macs, Apple defaults to a split-view styled navigation. That’s great, except that you must swipe in from the leading edge to show the initial view and it’s not apparent that you can do so.
You can override the default behavior by adding a call to .navigationViewStyle(_:) to your NavigationView. To set the default to a stack on all platforms, you add .navigationViewStyle(StackNavigationViewStyle()) to the bottom of NavigationView. However, for this project you won’t be needing to do that.
Next, you need to implement the views to show the flights arriving and departing from the airport. You’ll also learn more about working with data in your SwiftUI views.
Displaying a list of data
Open the file FlightBoard.swift. Right now, this is a default SwiftUI view. You will update it to display the flight information for arriving or departing flights, depending on what’s passed in as arguments.
Open FlightBoard.swift and add the following code after boardName:
var flightData: [FlightInformation]
This variable holds information about flights to display on the page.
You also need to update the preview to provide the expected values. Change the preview to read:
FlightBoard(boardName: "Test",
flightData: FlightInformation.generateFlights())
You should also update the navigation links you created in the previous step. Open ContentView.swift and change the navigation links inside the VStack to:
NavigationLink(destination: FlightBoard(
boardName: "Arrivals",
flightData: self.flightInfo.arrivals())) {
Text("Arrivals")
}
NavigationLink(destination: FlightBoard(
boardName: "Departures",
flightData: self.flightInfo.departures())) {
Text("Departures")
}
You’ve added the two parameters to be the views SwiftUI should transition to when the user taps the button. You also specify whether you only want arrivals or departures to pass only the flights going in the direction matching the link.
Go back to FlightBoard.swift. Having an array of data to display is a pretty common task, and each platform provides a way to work with this data in the array. SwiftUI provides a couple of ways to loop through data in your view.
The first SwiftUI method to loop through data is ForEach. Change the body of the view to:
VStack {
Text(boardName)
.font(.title)
ForEach(flightData, id: \.id) { flight in
Text("\(flight.airline) \(flight.number)")
}
}
In the canvas, you’ll see a new preview window for each flight that shows the airline and flight number. If you start Live View, you’ll see a stack of views.
ForEach iterates over the items in the passed data, calling the closure for each element and passing in the current element. In the closure, you define the view to display for the element. In this code, a Text view shows the flight airline and number.
The id: parameter hints that SwiftUI has expectations for the data passed to ForEach. In the next section, you’ll explore making your data work with SwiftUI.
Making your data more compatible with iteration
The data passed into ForEach must provide a way to identify each element of the array as unique. In this loop, you use the id: parameter to tell SwiftUI to use the \.id property as the unique identifier for each element in the array. The only requirement for the unique identifier is to implement the Hashable protocol, which the native Swift String and Int types do already. You can also use the Foundation UUID and URL types if need be. As .id is an Int, it works just fine as the unique identifier.
If your class implements Hashable, you can also use the entire object as the unique identifier. To do so, you would pass \.self as the id: parameter id. You can also use this technique to iterate over a set of integers or other objects that implement the Hashable protocol.
You can also remove the need to specify the unique identifier altogether by making your type conform to the Identifiable protocol. This protocol, new in Swift 5.1, provides a defined mechanism by which SwiftUI knows how to determine the unique identifier for a piece of data. The only requirement for this protocol is to have a property named id that conforms to the Hashable protocol. Since you already have such a property on the FlightInformation class, you simply have to let SwiftUI know this, and it can figure everything else out.
Open FlightInformation.swift. At the end of the file, right above the array extension, add the following code:
extension FlightInformation: Identifiable {
}
Adding the extension tells SwiftUI that FlightInformation implements Identifiable. Since FlightInformation already meets the protocol requirements, you don’t need to make any other changes.
Since you no longer need to specify the identifier for SwiftUI, open FlightBoard.swift and change the ForEach declaration to:
ForEach(flightData) { flight in
You’ll see the list works as before:
As the amount of data you display increases, it can become challenging to display it all in a single view. SwiftUI gives you tools to handle this situation, and you’ll explore one in the next section — the ScrollView.
Showing scrolling data
Open FlightBoard.swift and change the body of the view to:
VStack {
Text(boardName)
.font(.title)
ForEach(flightData) { flight in
VStack {
Text("\(flight.airline) \(flight.number)")
Text("\(flight.flightStatus) at \(flight.currentTimeString)")
Text("At gate \(flight.gate)")
}
}
}
Build and run the app. Navigate to a flight board, and you’ll immediately see the problem. There is so much data to display, that part of it runs off the bottom of the screen.
There are several ways to handle large collections of data that don’t fit neatly into your view. The first approach is to use a ScrollView to wrap data. Wrap the ForEach() iterator inside a ScrollView so it looks like this:
ScrollView {
ForEach(flightData) { flight in
VStack {
Text("\(flight.airline) \(flight.number)")
Text("\(flight.flightStatus) at \(flight.currentTimeString)")
Text("At gate \(flight.gate)")
}
}
}
The ScrollView wraps the enclosed view within a scrollable content region. This region allows the user to scroll through the data without affecting the rest of the view.
Build and run the app, and navigate to a flight board. You’ll see the title for the board no longer disappears off the view. If you drag over the list, you’ll see that you can scroll through the vertical list and no longer lose content off of your screen.
SwiftUI notices you’ve wrapped a VStack and applies vertical scrolling, and not horizontal. This means that if a line of text within the view became longer than the width of the view, SwiftUI wouldn’t automatically add horizontal scrolling.
You can override this default by passing in the desired scroll axes to ScrollView. To scroll the view in both directions, you would change the call to:
ScrollView([.horizontal, .vertical]) {
ScrollView provides a useful, generic way to let a user browse through a view. As a general solution, it would require more work to provide a polished view. For example, you may notice that the text gets truncated in some cases.
Preview the FlightBoard view on a larger device such as the iPhone 11 Pro Max and you’ll see:
You could fix this by adding .fixedSize(horizontal: true, vertical: false) to the offending text fields inside the VStack. But, there’s another option for displaying a single column list of data — the appropriately named List struct, which also provides built-in scrolling. In the next section, you’ll convert the flight board to use a List.
Creating lists
ForEach iterates over the elements of the array, but it relies on you to figure out what to do with that data. Since iterating through data and displaying it to the user is such a common task, all platforms have a built-in control for this task. SwiftUI provides the List struct, in addition to ForEach, that does the heavy lifting for you.
Using the List struct displays rows of data arranged in a single column, using a platform-appropriate control.
Open FlightBoard.swift. Delete the body of the view and replace it with:
VStack {
Text(boardName)
.font(.title)
List(flightData) { flight in
Text("\(flight.airline) \(flight.number)")
}
}
You’ll see there are few changes, other than changing the name of the struct. List uses the platform’s built-in list format to provide functionality without much work on your part. If you start Live View, you’ll see the list automatically supports scrolling, out of the box. If you’ve ever had to use UITableView in iOS, you can easily see that you’ve just created the same result in Swift UI — with a lot less effort.
The preview now shows a list of flights:
ForEach allows you to iterate over almost any collection of data and create any view you want for each element. List acts as a specific case of ForEach you use to display rows of one-column data. Almost every framework and platform provides a version of this control, as it’s a pretty common UX use case. When you need more flexibility to work with the data in the collection, you can use ForEach.
When the user selects a flight from the list, you want to show more information about that flight on a new view. Your first thought might be to wrap the list in FlightBoard.swift, so your code would look like this:
NavigationView {
List(flightData) { flight in
Text("\(flight.airline) \(flight.number)")
}
}
If you made this change, this would cause a problem because this view is already part of a navigation stack from the NavigationView you added in ContentView.swift.
If you added another NavigationView, you would end up with a view that looked like this when arriving on the page:
Having two backlinks breaks the concept of a navigation view. A navigation view creates a stack of views starting with the initial view. You should only ever have a single NavigationView in your app’s view hierarchy, or odd behavior will ensue.
Before getting to implementing the navigation, you must first add a little more information to the flight board. You first need to set the title for this view in the view stack. Remove the Text element from the beginning of the VStack. Then add the following after the List element to set the title for this view:
.navigationBarTitle(boardName)
You’ll see the title doesn’t appear above the list in the preview. If you start Live Preview, you’ll see the title also doesn’t show. That’s because the isolated view doesn’t have any way of knowing that it’s part of a view stack. Preview and Live Preview both only work on the view related to the current code.
Build and run the app so you can see your change in action. Navigate to one of the board pages, and you’ll see the title appear as expected:
This shows that when you’re working through views deeper down in your navigation stack, you can’t simply rely on the preview alone to ensure your view looks right.
You’re now going to create a separate view to display the information for each flight in the row. Create a new SwiftUI View named FlightRow.swift. Inside this view, above the body, add a variable to pass information for the flight to the view:
var flight: FlightInformation
Now replace the view in FlightRow.swift with:
HStack {
Text("\(self.flight.airline) \(self.flight.number)")
.frame(width: 120, alignment: .leading)
Text(self.flight.otherAirport)
.frame(alignment: .leading)
Spacer()
Text(self.flight.flightStatus)
.frame(alignment: .trailing)
}
Update the preview for this view to:
FlightRow(flight: FlightInformation.generateFlight(0))
Each row now shows the city and status for the flight in addition to the airline and flight number.
Go back to FlightBoard.swift. Change the enclosure of the list to use the new view:
FlightRow(flight: flight)
You’ll now see the preview shows your more complex row. Separating views in SwiftUI helps reduce the clutter and length of code; this makes your view more comfortable to read and to update in the future.
Next, you’ll need to add a view to show more details about a flight and connect the rows to this new view.
Adding navigation links
Create a new SwiftUI view named FlightBoardInformation.swift. You’ll use this view to provide more detailed information about the flight to the user.
In the new view, add a variable you’ll use to pass in the flight for which you want to display information for:
var flight: FlightInformation
Now change the view body to:
VStack(alignment: .leading) {
HStack{
Text("\(flight.airline) Flight \(flight.number)")
.font(.largeTitle)
Spacer()
}
Text("\(flight.direction == .arrival ? "From: " : "To: ")" +
"\(flight.otherAirport)")
Text(flight.flightStatus)
.foregroundColor(Color(flight.timelineColor))
Spacer()
}
.font(.headline)
.padding(10)
Also change the preview to provide a flight as follows:
FlightBoardInformation(flight:
FlightInformation.generateFlight(0))
The primary flow of a navigation view is a type of master-detail. This navigation follows the flow from more general information, to more specific information. Displaying details about a flight from a list of flights is a good use case for this navigation style. To create this navigation flow, you can add the link between the rows in FlightBoard.swift and this new view.
Go to FlightBoard.swift and change the view to:
List(flightData) { flight in
NavigationLink(destination: FlightBoardInformation(flight: flight)) {
FlightRow(flight: flight)
}
}
.navigationBarTitle(boardName)
This code should look familiar; it’s similar to the navigation links you added to the app’s start page at the beginning of this chapter. Again, you pass the view to display as the destination parameter when the user taps the button. You also define what to show in the enclosure; in this case, it’s a FlightRow view. Wrapping the navigation link inside a List means SwiftUI renders each item in the list as a separate navigation item. On iOS, you’ll get the small right disclosure arrow at the end of each row that you’re probably familiar with.
Build and run the app. Tap either of the two flight board choices, and then tap on a flight. You’ll see the flight details displayed:
Adding items to the navigation bar
Each view in the navigation view stack has a navigation bar. By default, the navigation bar contains a link back to the previous view. You can add additional items to the navigation bar if you need to, although you want to avoid overcrowding it with too many controls.
You’ll add a toggle to the navigation bar to hide canceled flights from the list. Open FlightBoard.swift and add the following code after the declaration of flightData:
@State private var hideCancelled = false
You set this state variable to hide cancelled flights. Now, add a computed property after the new state variable to filter flights based on this variable:
var shownFlights: [FlightInformation] {
hideCancelled ?
flightData.filter { $0.status != .cancelled } :
flightData
}
Change the variable passed to List to use the computed property, instead of the passed flights.
List(shownFlights) { flight in
With those changes, you can now filter the list of flights by changing the hideCancelled state variable using a toggle on the navigation bar. Add the following code after the navigationBarTitle(_:) method to add such a toggle.
.navigationBarItems(trailing:
Toggle(isOn: $hideCancelled, label: {
Text("Hide Cancelled")
})
)
The navigationBarItems(trailing:) method adds a button to the trailing edge of the navigation bar. There’s a corresponding method to add the button to the leading edge, should you ever need that. The toggle takes a binding to the hideCancelled state variable. Using the state variable lets SwiftUI handle refreshing and updating the list when the value changes.
As the preview doesn’t show the navigation bar, you won’t see the toggle on the preview. Build and run the app, navigate to one of the flight boards, and try out the toggle to see it in action.
Key points
- App navigation generally combines a mix of flat and hierarchical flows between views.
- Tab views display a flat navigation that allows quick switching between the views.
- Navigation views create a hierarchy of views as a view stack. The user can move further into the stack and can back up from within the stack.
- A navigation link connects a view to the next view in the view stack.
- You should only have one
NavigationViewin a view stack. Views that follow should inherit the existing navigation view. - You apply changes to the navigation view stack to controls in the stack, and not to the
NavigationViewitself. - A
ScrollViewwraps a section of a view within a scrollable region that doesn’t affect the rest of the view. - SwiftUI provides two ways to iterate over data. The
ForEachoption loops through the data allowing you to render a view for each element. - A
Listuses the platform’s list control to display the elements in the data. - Data used with
ForEachandListmust provide a way to uniquely identify each element. You can do this by specifying an attribute that implements theHashableprotocol, have the object implementHasbableor have your data implement theIdentifiableprotocol.
Where to go from here?
The first stop when looking for information on user interfaces on Apple platforms should be the Human Interface Guidelines on Navigation for iOS, watchOS and tvOS:
- iOS: https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/navigation/
- watchOS: https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/navigation/
- tvOS: https://developer.apple.com/design/human-interface-guidelines/tvos/app-architecture/navigation/
macOS navigation provides more options and creates a more complex topic. SwiftUI imposes some limitations that make it more like iOS development, and the above link provides a good starting point.
The WWDC 2019 SwiftUI Essentials video also provides an overview of Apple’s guidelines on how views, navigation and lists fit together: