Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

13. Navigation
Written by Bill Morefield

It’s rare to find an app 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.
  • 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. SwiftUI 4.0 introduced significant changes to navigation for SwiftUI apps. In this chapter, you’ll explore building a navigation structure for an app using these new views.

Getting Started

Open the starter project for this chapter; you’ll find a very early version of a flight-data app for an airport. You’ll build out the navigation for this app. You would likely get the flight information from an external API in a real-world app. For this app, you’ll be using mock data.

To start, 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 consistent flight data every time, with only the start date changing.

Now, open and examine FlightInformation.swift, which encapsulates information about flights. You’ll use this mock data throughout the following chapters.

Open WelcomeView.swift. The view includes a @StateObject named flightInfo that holds this mock data for the app.

Creating Navigation Views

Build and run the starter app. There’s a bare-bones implementation with a graphic and a single option to view the day’s flight status board.

Initial app
Initial app

Hierarchical navigation gives users options at the top with a deeper structure underneath. Before SwiftUI 4.0, you would use a NavigationView to build hierarchical navigation. The new types use a NavigationStack to produce single-column navigation and NavigationSplitView to produce multiple-column navigation. In this chapter, you’ll create this view to use hierarchical navigation compatible with multiple platforms.

The NavigationSplitView supports a split-view interface on larger devices, separating the app’s views into separate panes. One view generally remains static, while the second changes as the user navigates through the view stack. On smaller screens, like the iPhone, it falls back and works in a single column as a NavigationStack. Using a NavigationSplitView in a cross-platform app will make it easier for you to adapt to both small and large screens.

Open WelcomeView.swift and replace the view body with the following:

NavigationSplitView {
  Text("Sidebar")
} detail: {
  Text("Detail")
}

You use the init(sidebar:detail:) initializer of NavigationSplitView to create a view with two columns. You could create a third column using the init(sidebar:content:detail:) initializer, but two columns work better for this app. The view inside the view closure fills the entire screen for an iPhone.

Split Navigation on an iPhone
Split Navigation on an iPhone

You’ll see both columns on a larger device like an iPad. The sidebar will be initially hidden and can be shown by tapping the Show Sidebar button or making a sliding gesture from the left side of the screen.

Split Navigation on an iPad
Split Navigation on an iPad

With this structure in place, you can fill in the two columns. First, create the sidebar view to drive the navigation. Above the WelcomeView implementation, add the following code:

enum FlightViewId: CaseIterable {
  case showFlightStatus
}

struct ViewButton: Identifiable {
  var id: FlightViewId
  var title: String
  var subtitle: String
}

This code defines a new FlightViewId enumerable, which implements the CaseIterable protocol. Initially, it will have only a single case, showFlightStatus. You then define the ViewButton struct. This struct contains three properties: an id, title and subtitle. You’ll use these properties to define the buttons in the sidebar. The struct also implements the Identifiable protocol, which you learn more about in Chapter 14: “Lists”. For now, know that the id property fulfills the protocol.

Now, add the following code before the body of the view:

var sidebarButtons: [ViewButton] {
  var buttons: [ViewButton] = []

  buttons.append(
    ViewButton(
      id: .showFlightStatus,
      title: "Flight Status",
      subtitle: "Departure and arrival information"
    )
  )

  return buttons
}

This computed property will provide an array of ViewButton structs that define the options for the sidebar. You’ll add more buttons in this and the following chapters as you expand the Mountain Airport app.

There’s one more step before you build the sidebar. Add the following property after flightInfo:

@State private var selectedView: FlightViewId?

This property will store the FlightViewId when the user taps an item in the List. You use a nullable type to handle the case before the user taps a button.

Now you can create the list for the sidebar. Replace the Text("Sidebar") view with:

// 1
List(sidebarButtons, selection: $selectedView) { button in
  // 2
  VStack {
    Text(button.title)
    Text(button.subtitle)
  }
}
// 3
.listStyle(.plain)
.navigationTitle("Mountain Airport")

Here’s how this sets up the sidebar:

  1. You’ll find SwiftUI expects the sidebar to provide a list. You use the selection parameter to pass in the selectedView property. When the user taps a button from the list, SwiftUI will store the id property of the current ViewButton objects into the selectedView property. SwiftUI knows a change to the property passed to the selection parameter should cause state changes. If you change the property elsewhere, it’ll still trigger navigation.
  2. You display the button’s title and subtitle in a VStack.
  3. First, you specify the plain list style since you’ll add some polish to it in a moment. You then use the navigationTitle(_:) modifier to provide a title for the NavigationSplitView. It might seem odd to call navigationTitle(_:) on the List and not the NavigationSplitView. But remember, you’re defining a hierarchy of views. A view’s title typically changes when migrating through the view stack. The navigationTitle(_:) modifier locates the navigation view for the attached control and adjusts the title accordingly.

Build and run the app. On a small screen, the title appears above the list, while the list shows your single navigation item.

Sidebar with First Navigation Link
Sidebar with First Navigation Link

On a larger screen, you’ll see that the sidebar matches the small screen display while the details show the static Text view. In the next section, you’ll apply some styling to the list.

Polishing the Links

Before moving to the details view, you’ll improve the button’s appearance 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()
    )
  }
}

Here are a couple of things to note:

  1. Using maxWidth: .infinity sets the view to fill the available horizontal space.
  2. You also use the background(_:) modifier to provide an image background. You’ll learn more about this in Chapter 18: “Drawing & Custom Graphics”.

This change provides a more visually appealing view to replace the simple text link. It also provides 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 VStack view under // 2 with:

WelcomeButtonView(
  title: button.title,
  subTitle: button.subtitle
)

Run the app to see your new sidebar link.

Styled Sidebar Links
Styled Sidebar Links

Having improved the look of your sidebar, you’ll now put this list to work and implement the child view.

Building the Details View

Your details view will show the user a list of today’s flights. Open FlightStatusBoard.swift. At the top of the FlightStatusBoard struct, add a variable that you’ll use to pass in the list of flights for the day:

var flights: [FlightInformation]

Change the view body to:

List(flights, id: \.id) { flight in
  Text(flight.statusBoardName)
}
.navigationTitle("Today's Flight Status")

This code 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’ll learn more about lists in Chapter 14: “Lists”.

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’ll implement the details view for your NavigationSplitView. Go to WelcomeView.swift and replace the Text("Details") in the details closure with:

// 1
if let view = selectedView {
  // 2
  switch view {
  case .showFlightStatus:
    // 3
    FlightStatusBoard(flights: flightInfo.getDaysFlights(Date()))
  }
} else {
  // 4
  Text("Select an option in the sidebar.")
}

Here’s how this handles the implementation view:

  1. You attempt to unwrap selectedView into view. Once the user chooses an item from the List, view now contains the id for the selected item. Until then, the unwrap fails, and you’ll show the view under comment four.
  2. As view contains the FlightViewId for the selected item in the sidebar, you create a switch statement to handle this and future options.
  3. For the showFlightStatus case, you display the FlightStatusBoard view and pass in the flights for today.
  4. If the unwrap attempt in step one failed, you display a Text view asking the user to select an option.

Run the app. On the Welcome view, tap the Flight Status button. You’ll see your new view listing the day’s flights:

Flight list
Flight list

Next, you’ll extend the FlightStatusBoard view to support its own navigation structure.

Building a NavigationStack

With the two-column navigation you’ve created, you can implement the details views separately from the initial view. Your Flight Status option displays a list of today’s flights. Next, you’ll show information on a flight when the user taps it on the list. To do so, you’ll wrap this list inside a new NavigationStack implemented inside the overall NavigationSplitView details view.

The project already includes a file in the FlightDetails group named FlightDetails.swift that will show the details for a flight. To allow the user to see this view when tapping on a flight on the status board, go to FlightStatusBoard.swift and change the view to the following:

// 1
NavigationStack {
  List(flights, id: \.id) { flight in
    // 2
    NavigationLink(flight.statusBoardName, value: flight)
  }
  // 3
  .navigationDestination(
    // 4
    for: FlightInformation.self,
    // 5
    destination: { flight in
      FlightDetails(flight: flight)
    }
  )
  .navigationTitle("Today's Flight Status")
}

There’s a lot here, and if you’re familiar with the earlier navigation types in SwiftUI, it’ll look odd. Here’s how this implements the navigation to the flight details.

  1. First, you create a new NavigationStack. This shows a root view and presents subsequent views over the current view.
  2. Before SwiftUI 4.0, the NavigationLink expected a view to display to the user and a destination to present when the user tapped the displayed view. Now, instead of a view to show, you provide a value to the NavigationLink view. This line passes the FlightInformation object for this row of the List. You use a convenience initializer that takes a string and produces a Text view showing that string.
  3. You still need to let SwiftUI know to display a view. You use the navigationDestination(for:destination:) modifier to tell SwiftUI how to handle a value of a given type. Notice that you apply it to the List inside the NavigationStack. You should also not place it inside a looping container such as List, ScrollView, etc.
  4. The for parameter specifies the type of value this modifier will handle. In step two, you pass an instance of FlightInformation as the value property. Passing FlightInformation.self to for tells SwiftUI to use this method for values of the FlightInformation type.
  5. The destination parameter tells SwiftUI what to do when it’s passed the matching type. The closure receives a FlightInformation instance called flight, which is then used to initialize FlightDetails.

The preview will show you the new list. 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 NavigationStack:

Flight list with arrow
Flight list with arrow

Run the app and tap on Flight Status. Now tap on any flight, and you’ll see the details for that flight.

Flight details view
Flight details view

Now that you’ve implemented a stacked hierarchy, you’ll see how to customize the navigation bar in the next section.

Adding Items to the Navigation Bar

Creating a navigation view stack adds a navigation bar to each view. By default, the navigation bar only contains a button that returns to the previous view, 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.

Navigation Stack
Navigation Stack

Note: If you do not provide the title for a view, it’ll show as blank in the displayed list.

You can add additional items to the navigation bar, but you want to avoid overcrowding it with too many controls. Now, you’ll add a toggle to this app to hide flights that have landed or departed.

Still in FlightStatusBoard.swift, add the following code after the declaration of flights:

@State private var hidePast = false

You’ll 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 flights:

List(shownFlights, id: \.id) { flight in

With those changes, you can filter the list of flights by changing the hidePast state variable using a toggle on the navigation bar. Add the following code after the navigationTitle(_:) modifier to add such a toggle:

.navigationBarItems(
  trailing: Toggle("Hide Past", isOn: $hidePast)
)

The navigationBarItems(trailing:) modifier adds views to the trailing edge of the navigation bar. You’ll find a corresponding modifier, navigationBarItems(leading:), to add views to the leading edge, should you ever need that.

You provide the views inside the closure. Here you add a toggle that’ll change the hidePast property. As hidePast is a state variable, SwiftUI will refresh and update the list when the value changes. Also, you use the Button style toggle to conserve space.

Looking at the live preview will not show the new toggle because the preview has no idea the view will be inside a NavigationStack derived from another view. To see the live preview as it should appear, change the body of the preview to:

NavigationStack {
  FlightStatusBoard(
    flights: FlightData.generateTestFlights(date: Date())
  )
}

Since you wrapped the preview inside a NavigationStack, you’ll see the toggle appear in the preview. Run the app, navigate to one of the flight boards, and try the toggle to see it in action.

Toggle
Toggle

Great job!

Navigating With Code

As you saw earlier, passing data down the navigation stack is simple. You can send the data as a read-only variable or pass a binding to allow the child view to make changes that are reflected in the parent view. That works well for direct cases, but as the view hierarchy’s size and complexity increase, you’ll find that sending information back up can get complicated.

The navigation hierarchy also supports multiple paths to the same view. In these cases, you could end up having to pass parameters solely to pass data between other views:

Navigation diagram
Navigation diagram

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 other view in the hierarchy. You’ll now update the app to use this ability to save the most recent 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 line creates a StateObject you can now attach to the environment for the NavigationView. At the closing brace of the details closure, add the following code:

.environmentObject(lastFlightInfo)

This modifier adds the FlightNavigationInfo object to the environment for your navigation. You must add it to the NavigationSplitView and not to a view within it for the environment to flow through your view hierarchy.

Next, add a new case for the FlightViewId enum by adding the following to the end of it:

case showLastFlight

Now, add the following code to the sidebarButtons computed property just before the return statement:

if
  let flightId = lastFlightInfo.lastFlightId,
  let flight = flightInfo.getFlightById(flightId) {
  buttons.append(
    ViewButton(
      id: .showLastFlight,
      title: "\(flight.flightName)",
      subtitle: "The Last Flight You Viewed"
    )
  )
}

This code attempts to unwrap lastFlightInfo.lastFlightId. If successful, it then uses the getFlightById(_:) method to get the flight for the flight id and attempts to unwrap that. If both succeed, you have the last flight the user viewed in the flight variable. You then add a button to the sidebar navigation using the new showLastFlight type and with the name of the flight as the title.

You now need to implement the details for this new navigation option. Inside the details closure, find the switch view statement and add the following as the last case:

case .showLastFlight:
  if
    let flightId = lastFlightInfo.lastFlightId,
    let flight = flightInfo.getFlightById(flightId) {
    FlightDetails(flight: flight)
  }

Much as you did inside the sidebarButtons computed property, you attempt to unwrap the lastFlightInfo.lastFlightId property and use it to get the last flight corresponding to that id. If those succeed, you show the FlightDetails view showing that 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 in the onAppear(_) closure runs when the view appears. In this case, when SwiftUI renders the ZStack, it’ll 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 read the value and show the button.

This also causes the live view to crash because it doesn’t know about the new environment object. To fix this, you must provide an environment object for the preview. Adding the following modifier after preview’s NavigationStack:

.environmentObject(FlightNavigationInfo())

You also need to make the same change to FlightStatusBoard. Open FlightStatusBoard.swift and add the following modifier to the preview’s FlightStatusBoard:

.environmentObject(FlightNavigationInfo())

Run the app. 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, where the new button appears and shows the flight you selected. Tapping it takes you to the flight’s details.

Selected flight in Welcome view
Selected flight in Welcome view

In the next section, you’ll learn about programmatic navigation and the NavigationPath.

Navigating Using Code

The last flight option you added to the sidebar in the previous section brings up the details for the flight. While it works, it’s a good idea to let the user go back from these details to the same list of flights like when they select the Flight Status option. Until now, your navigation changes have come from user interaction. You’ll find times like this when you want to trigger navigation through your code. In this section, you’ll see how to interact with the navigation stack through code and use this to make this change.

First, you’ll need a way to provide a flight to the FlightStatusBoard view. Open FlightStatusBoard.swift and add the following property after flights.

var flightToShow: FlightInformation?

This code creates an optional property. You’ll change this view so if set; it will automatically navigate to the detail for that flight. If nil, then the view acts as it currently does.

Next, add the following state property after hidePast:

@State private var path: [FlightInformation] = []

This property contains an array of FlightInformation objects that you initialize to an empty array. Now update the declaration of the NavigationStack to:

NavigationStack(path: $path) {

By default, a NavigationStack manages the state of the navigation stack itself. You can pass an object through the path parameter to NavigationStack, and SwiftUI will share control of the stack through this object. In this case, you know all the values will be FlightInformation objects, so use an array of that type for the navigation path. Initializing it to an empty array starts as a stack with no views. In more complex cases where you can have multiple object types, you use a NavigationPath to store any value conforming to the Hashable protocol.

You can use the path property to modify the navigation stack programmatically. Add the following code after the closing brace of the NavigationStack:

.onAppear {
  if let flight = flightToShow {
    path.append(flight)
  }
}

The onAppear(perform:) modifier executes its closure before the attached view appears. In this case, the code block will run before the NavigationStack appears to the user. If you successfully unwrap the flightToShow property, you append the unwrapped object to the path property. Doing so has the same result as if the user tapped an item in the list for the flight and navigates to the details for this flight.

This action also shows the power of the new separation between navigation actions (through NavigationLink) and results (through navigationDestination(for:destination:)). You don’t need to add code telling SwiftUI how to handle appending a FlightInformation object since it already knows how to do so through your navigationDestination(for:destination:) for FlightInformation.self.

To finish the change, open WelcomeView.swift and find the code for the .showLastFlight: case of the switch statement. Replace the call to FlightDetails(flight: flight) with:

FlightStatusBoard(
  flights: flightInfo.getDaysFlights(Date()),
  flightToShow: flight
)

You now show the FlightStatusBoard and pass the flight in through the flightToShow parameter instead of showing the flight details directly.

Run the app. Tap Flight Status and then tap a flight. Return to the Welcome view, where the new button appears and shows the flight you selected. Tapping it navigates to the status board. After a brief pause, you’ll see the details for the flight appear. Tap and hold the Back button to confirm that the status board appears in the navigation.

Flight Details Showing Program Controlled Navigation
Flight Details Showing Program Controlled Navigation

Now that you’ve explored the navigation view, you’ll explore tabbed navigation and see how to integrate the two within the same app.

Using Tabbed Navigation

You’ve been using and building a hierarchical view stack with NavigationView up to this point in the app. Most apps use this structure, but there’s 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 new view above the FlightStatusBoard struct:

struct FlightList: View {
  var flights: [FlightInformation]
  var flightToShow: FlightInformation?
  @State private var path: [FlightInformation] = []

  var body: some View {
    NavigationStack(path: $path) {
      List(flights, id: \.id) { flight in
        NavigationLink(flight.statusBoardName, value: flight)
      }
      .navigationDestination(
        for: FlightInformation.self,
        destination: { flight in
          FlightDetails(flight: flight)
        }
      )
    }
    .onAppear {
      if let flight = flightToShow {
        path.append(flight)
      }
    }
  }
}

Next, delete the path property from the FlightStatusBoard view since you’ve moved the NavigationStack to the extracted view. 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")
  }
  // 5
  FlightList(
    flights: shownFlights,
    flightToShow: flightToShow
  )
  .tabItem {
    Image(systemName: "airplane")
      .resizable()
    Text("All")
  }
  FlightList(
    flights: shownFlights.filter { $0.direction == .departure }
  )
  .tabItem {
    Image("ascending-airplane")
    Text("Departures")
  }
}
.navigationTitle("Today's Flight Status")
.navigationBarItems(
  trailing: Toggle("Hide Past", isOn: $hidePast)
)

Here’s how the tab view code works:

  1. You declare that you’re creating a tab view with the TabView control. The first tab will show arriving flights, the second all flights and the third departing flights.
  2. You provide a view for each tab to the enclosure of TabView showing the extracted FlightList view. Note that you do not pass the flightToShow to the view since the flight may not appear in the departure or arriving lists.
  3. You apply the tabItem(_:) modifier to the tab to set an image, text, or combination of the two.
  4. Each tab displays an image and a text label. You can only use Text, Image, or an Image followed by Text as the tab label. If you use anything else, the tab will appear visible but empty. You don’t need to create a VStack even when using multiple items.
  5. You pass the flightToShow property only to the complete list of flights since there’s no guarantee it will appear in the other tabs. You’ll ensure this tab shows when the view needs to navigate to the chosen flight in a moment.

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 Image within the tab toolbar won’t 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 flights departing or arriving at the airport. Note that the toggle in the navigation still works. Also, the two navigation structures don’t conflict. You can select any flight as before and see more details about it.

Flight status with tabs
Flight status with tabs

Setting Tabs

Remembering the last tab selected when the user returns to the view would be a nice addition. To do that, in FlightStatusBoard.swift, below the hidePast state variable, add the following line:

@AppStorage("FlightStatusCurrentTab") var selectedTab = 1

You use 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. You now need to add a unique identifier to each tab. First, change the definition of the TabView to:

TabView(selection: $selectedTab) {

You now pass a binding to the selectedTab to the selection parameter of TabView. Using AppStorage persists the value to UserDefaults so that the app will remember the change for future access. You now need to attach an identifier to each tab. After the tabItem closure for the Arrivals tab, add the following modifier:

.tag(0)

You use the tag(_:) modifier to give each tab a unique identifier, in this case, an integer. You often use an enumerable here, but that complicates storing the value in this example. For the tab showing all flights, add the following code after the tabItem closure:

.tag(1)

Finally, after the tabItem closure for the Departures tab, add the following code:

.tag(2)

Both of these add identifiers to the remaining tabs; the binding works both ways. If you set selectedTab to a value, SwiftUI will activate the tab with the corresponding identifier. If the user changes the active tab, then selectedTab will receive the identifier of the chosen tab.

To ensure when you pass a value in the flightToShow property, the user sees the tab with all flights, add the following modifier at the end of the TabView closure before the navigationTitle(_:) modifier:

.onAppear {
  if flightToShow != nil {
    selectedTab = 1
  }
}

This code sets the selectedTab to one when you pass a value through the flightToShow property to ensure that the complete list of flights shows in the view since hidePast defaults to false. This way, the user will always see the flight in the list when they return from the details page.

Run the app. and then 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 return to the Welcome View. Now tap Flight Status again, and confirm that 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 the TabView.

Setting Tab Badges

SwiftUI 3.0 introduced controls that let you set a badge for each tab. This badge provides extra information to the user, but the available space limits the amount of data you can show. You’ll add a badge item to indicate the number of incoming and outgoing flights to the Flight Status and a short text badge showing the date.

First, open FlightStatusBoard.swift and add the following code to the first tabItem just before the .tag(0) line:

.badge(shownFlights.filter { $0.direction == .arrival }.count)

The simplest badge displays a number on the tab icon. Here, you use the same filter to limit the FlightList view and the count property to get the number of flights. Add a similar line to the last tabItem before the .tag(2) line:

.badge(shownFlights.filter { $0.direction == .departure }.count)

Again, you use the same filter and get a count with the count property of the collection. In most cases, you’ll add a number indicator to a tab, but you can also add a short text filter.

Add the following code to the top of the body after the shownFlights property:

var shortDateString: String {
  let dateF = DateFormatter()
  dateF.timeStyle = .none
  dateF.dateFormat = "MMM d"
  return dateF.string(from: Date())
}

This property returns a string with the month and date. You can then use this property as a badge. Add the following line after the second tabItem before the .tag(1) modifier:

.badge(shortDateString)

Now run the app, and tap on the Flight Status option. You’ll see the new badges on each tab at the bottom of the view:

Badges
Badges

Well done! 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

  • Starting in SwiftUI 4.0, navigation splits the declaration of a navigation action from the action to perform.
  • Split navigation can create a navigation structure with two or three columns. The framework will collapse the columns on small screen devices to appear identical to stack navigation.
  • Navigation stack creates a hierarchy of views. The user can move further into the stack and can back up from within the stack.
  • A NavigationLink shows a view that provides a value associated with the view. The navigationDestination modifier informs SwiftUI how to act when provided a value of a given type.
  • You apply changes to navigation views to controls in the stack and not to the navigation type itself.
  • You can access the view stack in your code by passing a binding to a mutable collection through the path parameter. In simple cases, this can be a collection of the type being used for navigation. If you need multiple types, you can use a NavigationPath.
  • Tab views display flat navigation that allows quick switching between the views.

Where to Go From Here?

To learn about migrating the older navigation types to the new ones introduced with SwiftUI 4.0, see Migrating to new navigation types at https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types.

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:

Navigation in macOS 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.