13.
Navigation
Written by Bill Morefield
It’s a rare app the one that can work with only a single view; most apps use many views and 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 a consistent 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 building a navigation structure for an app.
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 navigation for this app. In a real-world app, you would likely get the flight information from an API through Combine. For this app, though, you’ll be using mock data.
Expand the Models folder in the app. Open FlightData.swift, and you’ll find the implementation of the mock data for this app. The FlightData class generates a schedule for fifteen days of flights with thirty flights per day starting with today’s date using the generateSchedule() method. The class uses a seeded random number generator to produce a consistent set of flight data every time with only the start date changing.
Also open and examine FlightInformation.swift, which encapsulates information about flights. You’ll be using this mock data through the next several chapters while building out this app.
Open WelcomeView.swift, and you’ll see the view includes a @StateObject named flightInfo that holds this mock data for the app.
Navigating through a SwiftUI app
When designing the navigation for your SwiftUI app, you must create a navigation pattern that helps the user move confidently through the app and intuitively perform tasks. Your users will rarely notice well-done navigation, but they won’t stand for an app that’s hard to navigate or 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, clearly dividing 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. A hierarchical layout has fewer lop level views than a flat layout, but each contains a more in-depth 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 often combine these two methods. 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
Build and run the starter app. You’ll see a bare-bones implementation with a graphic and a single option to view the day’s flight status board. In this chapter, you’ll change this view to use a hierarchical navigation with a NavigationView.
A navigation view arranges multiple views into a stack, transitioning from one view to another. In each view, the user can work with controls on the view, and those controls may continue to the next 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 WelcomeView.swift and replace the view body with the following:
// 1
NavigationView {
ZStack(alignment: .topLeading) {
// 2
Image("welcome-background")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 250)
VStack(alignment: .leading) {
// 3
NavigationLink(
// 4
destination: FlightStatusBoard()
) {
// 5
Text("Flight Status")
}
Spacer()
}.font(.title)
.foregroundColor(.white)
.padding()
// 6
}.navigationBarTitle("Mountain Airport")
// End Navigation View
}
Here’s how this code sets up the app navigation:
-
NavigationViewdefines the starting point of the stack of views representing a path in the navigation hierarchy. Here, you start with only a single option but will add more in later chapters. Implementing the navigation view also creates a top toolbar and a link back to this view from child views. - You’ll learn more about graphics in a later chapter. For now, just know this places an image onto the view, resizing it to fill a 250 point height frame. SwiftUI renders views in a
ZStackfrom back to front. Placing the image first sets it behind other items in the view. - You use
NavigationLinkto create a way for the user to move deeper into the navigation stack. - The
destination:parameter specifies which view to show when the user taps the navigation link. Here the stack will change to theFlightStatusBoardview when the user triggers navigation. - You provide a view to display as the navigation link in the
NavigationLinkenclosure. At the moment, you’re just providing static text. - You use the
navigationBarTitle(_:)method to provide a title for theNavigationViewto display at the top. It might seem odd that you callnavigationBarTitle(_:)on theZStackand not theNavigationView. But remember, you’re defining a hierarchy of views. A view’s title typically changes when migrating through the view stack. ThenavigationBarTitle(_:)modifier locates the navigation view for the attached control and adjusts the title accordingly.
The preview shows your progress and the new link. You’ll note that the title appears above the image, unlike being inside the background image as in the initial view. SwiftUI does not currently provide a way to change the navigation text’s styling or apply a background without resorting to hacks that will likely break in a future release.
Run the app on an iPhone simulator or device. You’ll see the text now works as a button, and when you tap the button, the destination view appears.
Notice the back button shows the title of the previous view. If the title is too long to fit, then this will be replaced with < Back. Also, note there is no title for this view since one isn’t specified. The child view does not inherit the title of the parent view.
Now open the app in the iPad simulator. You’ll see something different, a blank screen with only a back navigation link at the top. Tapping it will reveal the navigation you just created, but it’s narrow and cut off from the sides.
On small iPhones 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 for many apps, but not for our case where we want the single navigation stack on all devices.
It is easy to override the default and get a consistent style on all platforms by adding a call to .navigationViewStyle(_:) to your NavigationView. Look for the // End NavigationView comment and add the following to the closing bracket on the next line:
.navigationViewStyle(StackNavigationViewStyle())
Run the app on an iPad or iPad simulator and you’ll see the view looks correct.
Sprucing up the links
Before moving to the child navigation views, you’ll improve the button’s look from the current plain text. Create a new SwiftUI View named WelcomeButtonView.swift. Replace the default view with the following:
struct WelcomeButtonView: View {
var title: String
var subTitle: String
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.title)
.foregroundColor(.white)
Text(subTitle)
.font(.subheadline)
.foregroundColor(.white)
}.padding()
// 1
.frame(maxWidth: .infinity, alignment: .leading)
// 2
.background(
Image("link-pattern")
.resizable()
.clipped()
)
}
}
A couple of things to note here:
- Using
maxWidth: .infinitysets the view to fill the available horizontal space. - You also use the
background(_:)modifier to provide an image background. You’ll learn more about this in Chapter 17: Drawing & Custom Graphics.
This change provides a visually more appealing view to replace the simple text link. It also provides a place for a short description to accompany each menu option.
Change the contents of the preview to provide default data:
WelcomeButtonView(
title: "Flight Status",
subTitle: "Departure and Arrival Information"
)
Go back to WelcomeView.swift. Replace the current Text view in the NavigationLink enclosure under // 5 with:
WelcomeButtonView(
title: "Flight Status",
subTitle: "Departure and arrival information"
)
Note that using a more complex view does not affect the operation of the navigation link.
Having created navigation links, you’re now going to put them to work and look at child views in a navigation stack.
Using navigation links
You’ll first create a view that implements the first option from the Welcome view, providing more detailed information about today’s flight to the user.
Open FlightStatusBoard.swift. At the top of the FlightStatusBoard struct, add a variable that you will use to pass in the list of flights for the day:
var flights: [FlightInformation]
Change the view body to:
var body: some View {
List(flights, id: \.id) { flight in
Text(flight.statusBoardName)
}.navigationBarTitle("Flight Status")
}
You’ll learn more about lists in Chapter 14: Lists. For now, just know that this will loop through the array of flights showing a row for each. You’ve also set the title for the navigation view to reflect the view’s purpose.
You also need to provide sample data for the preview. The mock data class provides a method .generateTestFlights(_) for this purpose. Change the preview to provide this sample data:
FlightStatusBoard(
flights: FlightData.generateTestFlights(date: Date())
)
Now you can connect the new view to the navigation structure. Go to WelcomeView.swift and change the link for the Flight Status button destination to:
NavigationLink(
destination: FlightStatusBoard(
flights: flightInfo.getDaysFlights(Date()))
) {
WelcomeButtonView(
title: "Flight Status",
subTitle: "Departure and arrival information"
)
}
Go back to FlightStatusBoard and show the Live Preview if it’s not visible. You’ll notice that the view doesn’t look like a navigation view. Also, neither the title that you provided nor the back button appears on the view.
In Live Preview, each view stands alone, so XCode doesn’t know it is part of a navigation view hierarchy. To fix this, you can manually wrap the preview inside a NavigationView. Change the preview to the following:
static var previews: some View {
NavigationView {
FlightStatusBoard(
flights: FlightData.generateTestFlights(date: Date())
)
}
}
You’ll see the title and navigation bar. Note the back button does not appear. Whenever you’re using the preview to design a view nested within the navigation hierarchy, this will help the view and app match.
Run the app. On the welcome view, tap the Flight Status button. You’ll see your new view listing the day’s flights.
Next, you’ll look at extending the view.
Extending the hierarchy
Your navigation follows the flow from more general information to more specific information. Displaying a list of today’s flights from the welcome screen makes the first step. Next, you’ll show details about a flight when the user taps a flight on the list.
The project already includes a file named FlightDetails.swift that will show the details for a flight. To add the new view to your navigation hierarchy go to FlightStatusBoard.swift and change the view to:
List(flights, id: \.id) { flight in
NavigationLink(
flight.statusBoardName,
destination: FlightDetails(flight: flight)
)
}.navigationBarTitle("Flight Status")
You’ve replaced the text with a NavigationLink that will move to the FlightDetails view when tapped. Note you are using a simplified variation of the control that takes a string for the button as the first parameter.
On iOS, you’ll get the small right-pointing disclosure arrow at the end of each row. This visual indicator shows the user that tapping the row will lead to more information and comes automatically when combining a List and NavgiationLink.
Run the app. Tap the Flight Status link, and then tap on any flight. You’ll see the flight details displayed:
Adding items to the navigation bar
Creating a navigation view stack adds a navigation bar to each view. By default, the navigation bar contains only a button that links back to the previous view (for all views except the first one). Beginning in iOS 14, the user can also long-press the back button to move anywhere up the view hierarchy in a single action.
Note: If you do not provide the title for a view, it will show blank in the displayed list.
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 hide flights that have already either landed or departed to this app.
Still in FlightStatusBoard.swift, add the following code after the declaration of flights:
@State private var hidePast = false
You will set this state variable to hide past flights. Now, add a computed property after the new state variable to filter flights based on this variable:
var shownFlights: [FlightInformation] {
hidePast ?
flights.filter { $0.localTime >= Date() } :
flights
}
Change the variable passed to List to use the computed property, instead of all flights.
List(shownFlights, id: \.id) { flight in
With those changes, you can now filter the list of flights by changing the hidePast 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("Hide Past", isOn: $hidePast)
)
The navigationBarItems(trailing:) method adds views to the trailing edge of the navigation bar. You’ll find a corresponding method navigationBarItems(leading:) to add views to the leading edge, should you ever need that. Using the state variable lets SwiftUI handle refreshing and updating the list when the value changes.
Since you wrapped the preview inside a NavigationView, you’ll see the toggle appear in the preview. Run the app, navigate to one of the flight boards, and try out the toggle to see it in action.
Navigation via code
The default navigation link responds to a user’s action, turning the view into a button. When the user taps that button, the movement to the next view triggers. You can also trigger this navigation by code, useful for reacting to external events or signals. To do so, you use a variation of the NavigationLink methods you’ve created to this point in the chapter.
Open WelcomeView.swift and add the following code to the top of the VStack.
NavigationLink(
destination: FlightDetails(flight: flightInfo.flights.first!),
// 1
isActive: $showNextFlight
// 2
) { }
The two differences from the NavigationLink you’ve used before are:
- The
isActiveparameter takes a binding to a boolean that will trigger the navigation. There’s also aselectionparameter that lets you bind to a nullable variable for more complex scenarios. It triggers the link when the variable matches a specified value using thetagparameter. - The
contentparameter is empty, meaning the navigation link won’t show on the view.
Below the @StateObject, add the following code to provide a trigger for this navigation link:
@State var showNextFlight = false
Lastly you’ll add a button to trigger the navigation. After the last NavigationLink in the VStack add the following code:
Button(action: {
showNextFlight = true
}) {
WelcomeButtonView(
title: "First Flight",
subTitle: "Detail for First Flight of the Day"
)
}
Run the app. Now tap on the new button, and you’ll see the details for the day’s first flight.
That may seem unexciting at first; you’ve replicated something you’ve already done with more code. Using the button to trigger the navigation is a means to an end. Anything could change the state variable: a push notification, a timer, or the completion of an asynchronous operation.
Now that you’ve worked with moving down the stack, you’ll look at passing data back up the navigation stack in the next section.
Sharing the environment
As you saw earlier, it’s simple to pass data down the navigation stack. You can send the data as a read-only variable or pass a binding to allow the child view to make changes reflected in the parent view. That works well for direct cases, but as the view hierarchy’s size and complexity increases, you’ll find that sending information back up can get complicated.
Adding to that complication, in the previous section, you saw that the navigation hierarchy supports multiple paths. In these cases, you could end up having to pass parameters solely to pass data between other views:
Fortunately, there’s a better way. A SwiftUI view automatically shares its environment with any view below it in the view hierarchy. This feature allows you to put anything into the environment, then view or modify it within any view. You’ll now update the app to use this ability to save the last flight a user viewed and show that in place of the first flight from the previous section.
First, you’ll create a class to add to the environment. Under the Models group, create a new file named FlightNavigationInfo.swift.
Change the file to read:
import SwiftUI
class FlightNavigationInfo: ObservableObject {
@Published var lastFlightId: Int?
}
The single property will store the id of the last flight the user views. Now, you’ll add this to the parent navigation view. Open WelcomeView.swift and, at the end of the variables at the top of the struct, add the following code:
@StateObject var lastFlightInfo = FlightNavigationInfo()
This creates a StateObject you can now attach to the environment for the NavigationView. At the closing brace of the navigation view (adjacent to the .navigationViewStyle(_:) modifier) add the following code:
.environmentObject(lastFlightInfo)
This method adds the FlightNavigationInfo object to the environment for the NavigationView. You must add it to the NavigationView and not to a view within it for the environment to flow through your view hierarchy.
While here, change the First Flight button to show this value when present. Replace the code for the Button before the Spacer with:
// 1
if
let id = lastFlightInfo.lastFlightId,
let lastFlight = flightInfo.getFlightById(id) {
Button(action: {
// 2
showNextFlight = true
}) {
WelcomeButtonView(
// 3
title: "Last Flight \(lastFlight.flightName)",
subTitle: "Show Next Flight Departing or Arriving at Airport"
)
}
}
Here’s what this does:
- If you used the first version of SwiftUI, you’ll likely be happy to see you can now use the
if—letsyntax to unwrap optionals. Here, you use this feature to show the button only when data is present. - You’ll keep using the boolean trigger created in the previous section, but this would work with any type of
NavigationLink. - You use the result of the
if—let, saving you the need to unwrap or provide default values within the view.
You also need to change the NavigationLink this button controls to use the Environment Object:
if
let id = lastFlightInfo.lastFlightId,
let lastFlight = flightInfo.getFlightById(id) {
NavigationLink(
destination: FlightDetails(flight: lastFlight),
isActive: $showNextFlight
) { }
}
Like when showing the button, you now only add the link when data is present and move to the last viewed flight.
The last step is to set the value through the environment when the user views a flight’s details. Open FlightDetails.swift and add a reference to the environment object to the view after the flight property:
@EnvironmentObject var lastFlightInfo: FlightNavigationInfo
With this reference to the view’s environment, add the following code after the closing brace for the ZStack.
.onAppear {
lastFlightInfo.lastFlightId = flight.id
}
Any code place in the onAppear(_) closure runs when the view appears. In this case, when SwiftUI renders the ZStack it will execute the code and store the id for this flight in the environment. When the user returns to the root welcome view, that view will pick up the value and show the button.
Finally, fix the preview by adding this to FlightDetails:
.environmentObject(FlightNavigationInfo())
Run the app. You’ll see the second button does not show since the identifier is initially nil. Tap Flight Status and then tap any flight. Return to the Welcome view, and you’ll see that the button appears and shows the flight you selected.
Now that you’ve explored the navigation view, you’ll explore tabbed navigation and see how you can integrate the two within the same app.
Using tabbed navigation
You’ve been using and building a hierarchical view stack with NavigationView to this point in the app. Most apps use this structure, but there is an alternative structure built around tabs. Tabs work well for content where the user wants to flip between options. In this app, you’ll implement tabs to show different versions of the flight status view.
Open FlightStatusBoard.swift. First, you’ll extract the portion of the view that creates the list into a separate view. This change will make it easier to use across the tabs. Add the following code above the FlightStatusBoard struct:
struct FlightList: View {
var flights: [FlightInformation]
var body: some View {
List(flights, id: \.id) { flight in
NavigationLink(
flight.statusBoardName,
destination: FlightDetails(flight: flight)
)
}
}
}
Next change the body of FlightStatusBoard to:
// 1
TabView {
// 2
FlightList(
flights: shownFlights.filter { $0.direction == .arrival }
)
// 3
.tabItem {
// 4
Image("descending-airplane")
.resizable()
Text("Arrivals")
}
FlightList(
flights: shownFlights
)
.tabItem {
Image(systemName: "airplane")
.resizable()
Text("All")
}
FlightList(
flights: shownFlights.filter { $0.direction == .departure }
)
.tabItem {
Image("ascending-airplane")
Text("Departures")
}
}.navigationTitle("Flight Status")
.navigationBarItems(
trailing: Toggle("Hide Past", isOn: $hidePast)
)
Here’s how the tab view code works:
- You first declare that you’re creating a tab view using the
TabViewcontrol. - You provide a view for each tab to the enclosure of
TabView. Each view becomes a tab’s contents, while modifiers on the view define the tab’s information. - You apply the
tabItem(_:)modifier to the tab to set an image, text, or combination of the two. - Each tab displays an image and a text label. You can only use
Text,Image, or anImagefollowed byTextas the tab label. If you use anything else, then the tab will show as visible but empty. Note that you don’t need to create aVStackeven when using multiple items.
Note: You may wonder why the view uses a custom image for the descending and ascending aircraft instead of modifying the SF Symbol font used for the central tab. Most modifiers to
Imagewithin the tab toolbar will not process, including a rotation.
Run the app. Tap on the Flight Status option, and you’ll see that your view now has three tabs allowing you to view all flights or only flight departing or arriving at the airport. Note that the toggle in the navigation still works. Also, the two navigation structures do not conflict. You can select any flight as before and see more details about it.
Setting tabs
It would be a nice addition to remember the last tab selected when the user returns to the view. Still in FlightStatusBoard.swift, below the hidePast state variable add the following line:
@AppStorage("FlightStatusCurrentTab") var selectedTab = 1
This uses the new @AppStorage feature to persist an integer to UserDefaults. You also specify a default to use the first time the view displays on a device. Change the view to:
// 1
TabView(selection: $selectedTab) {
FlightList(
flights: shownFlights.filter { $0.direction == .arrival }
).tabItem {
Image("descending-airplane")
.resizable()
Text("Arrivals")
// 2
}
.tag(0)
FlightList(
flights: shownFlights
).tabItem {
Image(systemName: "airplane")
.resizable()
Text("All")
}
.tag(1)
FlightList(
flights: shownFlights.filter { $0.direction == .departure }
).tabItem {
Image("ascending-airplane")
Text("Departures")
}
.tag(2)
}.navigationTitle("Flight Status")
.navigationBarItems(
trailing: Toggle("Hide Past", isOn: $hidePast)
)
- You pass a
selectionbinding toTabViewthat causes SwiftUI to use this value to reflect the currently selected tab. The tab view will initially be the tab with an identifier that matches theselectedTabvariable. When the user selects another tab,selectedTabwill update to the identifier for this tab. UsingAppStoragepersists the value to UserDefaults so that the app will remember the change for future access. - You use the
tag(_:)modifier to give each tab a unique identifier, in this case, an integer. You would often use an enumerable here, but that would complicate storing the value in this example.
Run the app. Tap Flight Status. You’ll see the view defaults to the All tab since the tag for it matches the default value you provided of 1. Select another tab and then tap the Back button to go back to the Welcome View. Now tap Flight Status again and confirm that view starts with the tab you selected in the previous step.
Note: If your app design works better with pages, you can change the tabs into pages with the
tabViewStyle(_:)modifier on theTabView.
You’ve now built a navigation structure for the app. In the next chapter, you’ll learn more about showing data in a view, including the List you used in this chapter.
Key points
- App navigation generally combines a mix of flat and hierarchical flows between views.
- Tab views display 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.
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 and navigation fit together: