8.
Navigation
Written by Aaqib Hussain
Previously, you learned to develop a framework and publish it as a Swift package. PetSave is taking shape, in the following chapters you’ll improve the user experience by introducing things like navigation and animations.
Navigation allows you to create experiences for your users. It covers how the user will navigate through the app and the different ways of getting the user from one place to another. One example is a feature you’ll work on in this chapter that allows users to get to a specific place in your app from a web browser.
Please note that this chapter is optional. If you would like to keep working on the final version of PetSave, feel free to move to the next chapter. Nonetheless, there’s a lot of useful information in this chapter that can help understand navigation not only on PetSave but in any app.
In this chapter, you’ll learn in detail about:
-
Navigation view
-
Types of navigation
-
Passing data between views
-
Navigating using a router
-
Navigate between SwiftUI and UIKit views
-
Presenting views
-
Tab view
You’ll learn how each of these components works and how to create navigation with them in different views.
It all starts with the navigation view.
Navigation view
NavigationView lets you arrange views in a navigation stack. Users can navigate to a destination view via a NavigationLink. The destination view is pushed into the stack. Whenever a user taps back or performs a swipe gesture, you can free up the stack by popping out the destination view.
You style the NavigationView with navigationViewStyle(_:). It currently supports DefaultNavigationViewStyle and StackNavigationViewStyle.
-
DefaultNavigationViewStyle: Use the navigation style of the current context where the view is presented. -
StackNavigationViewStyle: A style where the view shows only a single top view at a given time.
Note:
DoubleColumnNavigationViewStyleis now deprecated. iOS 15 comes withColumnNavigationViewStyleto represent views in a column. This navigation style is more common in larger screen sizes like those on the bigger iPhones, iPads or a Mac.
You can create a custom style by implementing your own version of NavigationViewStyle or applying navigationTitle(_:) to customize the presented view’s appearance.
Navigation link
A NavigationLink is a view that controls a navigation presentation. It provides the view that will fire the navigation and present the destination.
NavigationLink can reside directly inside a NavigationView. It’s commonly used with a List or Button or on some action performed by the user.
Open AnimalListView.swift and take a look at body:
var body: some View {
// 1
List {
// 2
ForEach(animals) { animal in
// 3
NavigationLink(destination: AnimalDetailsView()) {
AnimalRow(animal: animal)
}
}
footer
} // 4
.listStyle(.plain)
}
Here’s a code breakdown:
- The top-level view is
List. -
Listnests aForEachto render the animals. -
NavigationLinktakes in the destination view you want to show. When the user taps this view, the destination view gets nested inside the current view. - The list style is plain.
To render only the text that the user taps to go to the destination view, use a convenience initializer that takes in a string and creates the Text view. Replace the entire ForEach with:
ForEach(animals) { animal in
NavigationLink(
animal.name ?? "",
destination: AnimalDetailsView()
)
}
Note: In Xcode, double-click any curly opening brace to select the entire code block within the curly opening and closing braces. You can also use Command-/ to comment the entire code block or press delete to delete the entire code block.
Build and run. You’ll see this render:
Alternatively, you can do navigation programmatically, using the navigation link like this:
@State var shouldShowDetails: Int? = -1
var body: some View {
List {
ForEach(Array(animals.enumerated()), id: \.offset) { index, animal in
NavigationLink(
animal.name ?? "",
destination: AnimalDetailsView(),
tag: index,
selection: $shouldShowDetails
)
}
footer
}
.listStyle(.plain)
}
You use the navigation link’s initializer with selection and tag parameters. Here, the navigation view presents the destination view when the index matches the bound property shouldShowDetails.
Types of navigation
Navigation plays a vital role in giving the user a seamless experience. You must implement navigation so that the app works smoothly. Apple provides three styles of navigation:
- Hierarchical Navigation
- Flat Navigation
- Content-Driven or Experience-Driven Navigation
Hierarchical navigation
In hierarchical navigation, the root view is the navigation view. You go from one screen to another. The navigation view pushes these screens into a navigation stack. You’ll find this navigation style in the Settings and Mail apps.
Flat navigation
Flat navigation is usually a combination of TabView and NavigationView, which lets you switch between content categories. The Music and App Store apps are examples of such navigation.
Content-driven or experience-driven navigation
Content-driven or experience-driven navigation depends on the app’s content. Navigation may also depend on a user navigating to a particular screen. The Games and Books apps are examples of Content-Driven navigation.
It’s not written in stone that you can only use one of these navigation styles in a given instance. For example, you may combine flat with hierarchical. Many apps use multiple navigation styles.
However, it’s important to always give the user a clear path for navigation. Users must know where they are in your app at all times. Make sure you provide users with the minimum number of taps or swaps to reach their destination.
Passing data between views
There are four ways to pass data:
- Use a property.
- Use
@Stateand@Binding. - Use
@StateObjectand@ObservedObject. - Use view’s environment.
Using a property
Take it step by step. First, how do you use a property to pass data between views? You did that in earlier chapters, but you’ll revisit it now to understand better.
Take a look at this code example:
struct AnimalDetailsView: View {
var name: String
var body: some View {
Text(name)
}
}
The name property is of type String and passes into the Text view.
Then, consider this:
NavigationLink(
destination: AnimalDetailsView(
name: animal.name ?? "")
){
AnimalRow(animal: animal)
}
The AnimalDetailsView takes in the name of an animal object. If an animal isn’t given a name, it takes in a default empty string.
Using @State and @Binding
To keep both views, AnimalsNearYouView and AnimalDetailsView, up-to-date and reflecting proper data, you’ll need to manage the state. The sender view holds the data in a property marked with @State. The receiver receives the latest data with @Binding. This type of data passing assures both views stay updated. No matter where the data changes, both views get notified.
To better understand this concept, you’ll make a small tweak to the code. You’ll add a button to the list to enable or disable the user interaction of the navigation link on AnimalsNearYouView, this time using @State and @Binding. You’ll add two buttons in the views AnimalListView and AnimalDetailsView to control the state from both views.
First, open AnimalDetailsView.swift and replace AnimalDetailsView implementation with:
struct AnimalDetailsView: View {
var name: String
// 1
@Binding var isNavigatingDisabled: Bool
var body: some View {
Text(name)
// 2
Button(isNavigatingDisabled ? "Enable Navigation" : "Disable Navigation") {
isNavigatingDisabled.toggle()
}
}
}
- You add the receiver view that gets the
@Bindingproperty wrapper to listen to/return the changes from/to the sender. - A button to toggle the
isNavigatingDisabledto eithertrueorfalse.
Update your AnimalsView_Previews, so you pass the new parameter to AnimalDetailsView:
AnimalDetailsView(name: "Snow", isNavigatingDisabled: .constant(false))
Here you pass a constant to isNavigatingDisabled, so the view can be rendered in Xcode previews.
Now, open AnimalListView.swift and add a @State variable to the navigation state in the sender view:
@State var isNavigatingDisabled = false
Then, add a button nesting List before the ForEach:
Button(isNavigatingDisabled ? "Enable Navigation" : "Disable Navigation") {
isNavigatingDisabled.toggle()
}
This button will toggle the state of the navigation.
Still in AnimalListView.swift replace the ForEach with:
ForEach(animals) { animal in
// 1
NavigationLink(
destination: AnimalDetailsView(
name: animal.name ?? "",
isNavigatingDisabled: $isNavigatingDisabled
)
) {
AnimalRow(animal: animal)
}
.disabled(isNavigatingDisabled) // 2
}
Here, you are:
- Passing the navigation state to
AnimalDetailsView. - Depending on the state enabling or disabling the user interaction.
Build and run. You’ll see a view like this:
Tap Disable Navigation. You’ll see disabled navigation.
Similarly, enable navigation and select any animal. On AnimalDetailsView, you’ll see a Disable Navigation button.
Tap Disable Navigation and go back. You’ll see disabled navigation on AnimalsNearYouView like this:
Using @StateObject and @ObservedObject
@StateObject holds the object responsible for updating the UI. You use it to refer to a class-type property in a view. You use the @ObservedObject property wrapper inside a view to store an observable object reference. Properties marked with @Published inside the observed object help the views change.
One difference between @State and @StateObject is that the first one works with value types and the latter with reference types.
Back in AnimalsNearYouView.swift, add the following code:
class NavigationState: ObservableObject {
@Published var isNavigatingDisabled = false
}
With this, you created NavigationState which has one @Published property called isNavigatingDisabled. isNavigatingDisabled observes the value and maintains the state of the navigation view.
Now, open AnimalDetailsView.swift. Add a property navigationState with property wrapper @ObservedObject like this:
@ObservedObject var navigationState: NavigationState
Here using the @ObservedObject so the UI updates when the state changes. Also, remove the following property, as it’s no longer needed:
@Binding var isNavigatingDisabled: Bool
Then, update body to:
var body: some View {
Text(name)
Button(
navigationState.isNavigatingDisabled ?
"Enable Navigation" :
"Disable Navigation"
) {
navigationState.isNavigatingDisabled.toggle()
}
}
Here, the button action uses the published property isNavigatingDisabled in NavigationState to toggle the state.
Then, in the preview code, update AnimalDetailsView to:
AnimalDetailsView(
name: "Snow",
navigationState: NavigationState()
)
This code updates the preview with the latest changes in AnimalDetailsView.
Open AnimalListView.swift and add:
@StateObject var navigationState = NavigationState()
@StateObject keeps the current state of the observable object.
Then, delete isNavigatingDisabled as it’s no longer in use.
@State var isNavigatingDisabled = false
Update the button in the List to:
Button(
navigationState.isNavigatingDisabled ?
"Enable Navigation" : "Disable Navigation"
) {
navigationState.isNavigatingDisabled.toggle()
}
The button now uses the updated @StateObject.
Then, update ForEach in the List to:
ForEach(animals) { animal in
NavigationLink(
destination: AnimalDetailsView(
name: animal.name ?? "",
navigationState: navigationState
)
) {
AnimalRow(animal: animal)
}
.disabled(navigationState.isNavigatingDisabled)
}
You pass the state object to the AnimalDetailsView initializer.
Build and run.
Tap any animal to go to the animal’s details view.
You’ll find that everything works as before.
Using view’s environment
Environment objects can help you synchronize views. It catches the objects that are injected into the SwiftUI environment.
Back in AnimalDetailsView.swift, update the navigationState like this:
@EnvironmentObject var navigationState: NavigationState
This snippet captures the object that is injected into the environment.
Then, in the preview, update AnimalDetailsView to:
AnimalDetailsView(name: "Snow").environmentObject(NavigationState())
The preview is now in-sync with the latest changes made to the view.
In AnimalListView.swift, make sure the navigationState is a @StateObject:
@StateObject var navigationState = NavigationState()
Next, update the ForEach to:
ForEach(animals) { animal in
NavigationLink(
destination: AnimalDetailsView(name: animal.name ?? "")
.environmentObject(navigationState)
) {
AnimalRow(animal: animal)
}
.disabled(navigationState.isNavigatingDisabled)
}
Here, you inject the navigationState from the sender view in the environment.
Now, build and run.
Select an animal and go to the details view.
Play around with the app. Everything works smoothly as it previously did.
Now that you have a sense of how to pass around data. You’ll use that and learn another different way of doing navigation.
Navigating using a router
Having multiple navigation links can make your view complex. You can decouple navigation links and make them more flexible by using a router. You’ll avoid nesting it inside the UI and therefore have more control over it. Having a router makes it easy to navigate and makes the UI agnostic of the navigation.
You can customize the view a lot before navigating to that screen. A router provides a layer of abstraction. If you navigate to a UIViewController, your SwiftUI view won’t be affected in any way.
To gain a better understanding, try this quick exercise.
Under Core/utils, create a new file named NavigationRouter.swift and add:
import SwiftUI
protocol NavigationRouter {
// 1
associatedtype Data
// 2
func navigate<T: View>(
data: Data,
navigationState: NavigationState,
view: (() -> T)?
) -> AnyView
}
Here’s a code breakdown:
- During the implementation of this protocol, you need to provide the data type you want to pass to the destination view.
- Calling this method inside the view with the appropriate data returns a destination view. It also requires you to pass the state.
Now open AnimalDetailsView.swift and add:
struct AnimalDetailsRouter: NavigationRouter {
// 1
typealias Data = AnimalEntity
func navigate<T: View>(
data: AnimalEntity,
navigationState: NavigationState,
view: (() -> T)?
) -> AnyView {
AnyView( // 2
NavigationLink(
destination: AnimalDetailsView(name: data.name ?? "")
.environmentObject(navigationState) // 3
) {
view?()
}
)
}
}
AnimalDetailsRouter implements NavigationRouter with the implementation for navigating between views.
- Data type is
AnimalEntity. - This returns an
AnyViewwithin aNavigationLinkwithAnimalDetailsViewas the destination view. - Passing the
navigationStatetowards theAnimalDetailsView.
Now picking up the previous example about enabling/disabling the user interaction on navigation using buttons in both views.
Back in AnimalListView.swift, add the following properties to the struct:
let router = AnimalDetailsRouter()
Here, you add a router object to initiate navigation.
Then inside the ForEach, replace NavigationLink with:
router.navigate(
data: animal,
navigationState: navigationState
) {
AnimalRow(animal: animal)
}
.disabled(navigationState.isNavigatingDisabled)
The router returns a view nested with a navigation link and performs the required navigation.
Now, build and run. You’ll see:
Select an animal, and you’ll see the AnimalDetailsView:
Then, select the Disable Navigation button and go back to AnimalsNearYouView. You’ll see a disabled navigation.
It works as expected. That’s great!
Navigating using a router to a UIViewController
You learned how to use a router. Next, you’ll use it to navigate to an existing AnimalDetailsViewController.swift in UIKit.
Open AnimalDetailsViewController.xib. You’ll see a screen with a UILabel and a UIButton.
To make the UIViewController talk to the SwiftUI view, you’ll implement a bridge between them. SwiftUI provides a protocol, UIViewControllerRepresentable, and requires you to provide the UIViewController to which you want to connect. You also need to provide a method to reflect the changes you want to make to this view controller.
Under AnimalDetails/views, create AnimalDetailsViewRepresentable.swift. Then add:
import UIKit
import SwiftUI
struct AnimalDetailsViewRepresentable: UIViewControllerRepresentable {
// 1
var name: String
// 2
@EnvironmentObject var navigationState: NavigationState
// 3
typealias UIViewControllerType = AnimalDetailsViewController
// 4
func updateUIViewController(
_ uiViewController: AnimalDetailsViewController,
context: Context) {
// 5
uiViewController.set(
name,
status: navigationState.isNavigatingDisabled
)
// 6
uiViewController.didSelectNavigation = {
navigationState.isNavigatingDisabled.toggle()
}
}
// 7
func makeUIViewController(context: Context)
-> AnimalDetailsViewController {
let detailViewController =
AnimalDetailsViewController(
nibName: "AnimalDetailsViewController",
bundle: .main
)
return detailViewController
}
}
Here’s what the code is doing:
- Creates a variable to receive the name of the animal from the
AnimalsNearYouView. - Gets the state of the navigation in the current environment.
- Assigns the type of the destination view controller. Here, it’s the
AnimalDetailsViewController. - This method is from the
UIViewControllerRepresentable. Here, you update the changes coming from the SwiftUI view. - You set the name of the animal and the status of the navigation here.
- This closure listens to the button inside the
AnimalDetailsViewControllerand toggles the navigation state. - This method is also from
UIViewControllerRepresentable. Here, you return the view controller you want SwiftUI to make renderable.
SwiftUI requires both of these methods’ implementation to make and update the views.
Now, open AnimalDetailsView.swift. Then, in AnimalDetailsRouter, update navigate method as:
func navigate<T: View>(
data: AnimalEntity,
navigationState: NavigationState,
view: (() -> T)?
) -> AnyView {
AnyView(
NavigationLink(
destination: AnimalDetailsViewRepresentable(
name: data.name ?? ""
).environmentObject(navigationState)
) {
view?()
}
)
}
Here, you replace the AnimalDetailsView with the new representable view.
Now, build and run. Select an animal and go to the AnimalDetailsView. The navigation works fine.
You can’t even tell the difference in the look and feel, right? That’s how cool SwiftUI is.
Presenting views
SwiftUI provides you with two ways of presenting a view: Full screen cover and Sheet.
Full screen cover
You use full screen when you want to cover the entire screen and don’t want the user to swipe down to close the screen.
Sheet
Use a sheet when you want to let the user swipe the current view down to close it. This swiping to close feature is something you can disable as well.
Open AppMain.swift. You’ll see that ContentView uses .fullScreenCover. Now, to see how .sheet view modifier behaves, replace:
ContentView().fullScreenCover(
isPresented: $shouldPresentOnboarding,
onDismiss: nil
)
With:
ContentView().sheet(
isPresented: $shouldPresentOnboarding,
onDismiss: nil
)
Delete the app, so the onboarding screens show again. Build and run. You’ll see this view:
Both of these ways present the view in a bottom-to-top fashion.
Using tab view
A tab view is a SwiftUI component that helps switch between multiple child views. It’s an example of flat navigation. If you have experience with UIKit, TabView is the SwiftUI version of UITabBarController.
To create a user interface with TabView, you place views inside the TabView. Then you add .tabItem(_:) to the view contained inside the TabView, which helps toggle between the views.
To get a better understanding, open ContentView.swift and look at the following code:
var body: some View {
TabView {
// 1
AnimalsNearYouView(
viewModel: AnimalsNearYouViewModel(
animalFetcher: FetchAnimalsService(
requestManager:
RequestManager()
),
animalStore: AnimalStoreService(
context: PersistenceController.shared.container.newBackgroundContext()
)
)
)
.tabItem {
Label("Near you", systemImage: "location")
}
.environment(\.managedObjectContext, managedObjectContext)
// 2
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.environment(\.managedObjectContext, managedObjectContext)
}
}
This code creates two child views inside a TabView.
- The first tab item selection displays
AnimalsNearYouView. - The second tab item selection shows
SearchView.
Note:
.tabItem(_:)only supports aTextor anImage, aTextwith anImage, or aLabel. Adding another type of view results in empty tab item.
To assign a badge on your tab item, add .badge(2) before the Near you tabItem:
.badge(2)
Now, you’ll see 2 on the Near you tab.
Build and run. You’ll get the following result:
You can also use the selection initializer to perform navigation in TabView. The cool thing about selection is that it’s not limited to Int data type. You can pass in any object that conforms to Hashable.
Imagine you want to toggle between the child views within the TabView, AnimalsNearYouView and SearchView programmatically.
Start by creating PetSaveTabType.swift under Core/views. Then add:
enum PetSaveTabType {
case nearYou
case search
}
Since there are two child views in the TabView, you add two cases in the enum.
Under the same group, create PetSaveTabNavigator.swift and add:
class PetSaveTabNavigator: ObservableObject {
// 1
@Published var currentTab: PetSaveTabType = .nearYou
// 2
func switchTab(to tab: PetSaveTabType) {
currentTab = tab
}
}
// 3
extension PetSaveTabNavigator: Hashable {
static func == (
lhs: PetSaveTabNavigator,
rhs: PetSaveTabNavigator
) -> Bool {
lhs.currentTab == rhs.currentTab
}
func hash(into hasher: inout Hasher) {
hasher.combine(currentTab)
}
}
Here’s an explanation to the code:
- The
@Publishedproperty informs the UI as soon as its value changes. - A method to set the different types of tabs.
- Since you’ll be using a custom type you need to conform to
Hashable.
Now, back in ContentView.swift, create an object of PetSaveTabNavigator inside the ContentView:
@StateObject var tabNavigator = PetSaveTabNavigator()
The @StateObject creates the object and maintains its state. It refreshes the entire UI based on this object.
You’ll also update the TabView with its selection initializer and add tags to the views. Replace the body with:
var body: some View {
// 1
TabView(selection: $tabNavigator.currentTab) {
AnimalsNearYouView(
viewModel: AnimalsNearYouViewModel(
animalFetcher: FetchAnimalsService(
requestManager:
RequestManager()
),
animalStore: AnimalStoreService(
context: PersistenceController.shared.container.newBackgroundContext()
)
)
)
.badge(2)
// 2
.tag(PetSaveTabType.nearYou)
.tabItem {
Label("Near you", systemImage: "location")
}
.environment(\.managedObjectContext, managedObjectContext)
SearchView()
.tag(PetSaveTabType.search) // 3
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.environment(\.managedObjectContext, managedObjectContext)
}
}
Here’s a code breakdown:
- You pass in the
currentTabfrom thePetSaveTabNavigatorobject to the selection initializer. - Then, you apply a tag to
AnimalsNearYouView. - Finally, you apply a tag to
SearchView.
Now you can change the tabs programmatically. How? call switchTab(_:) which resides inside PetSaveTabNavigator. Then you’ll see the tabs switch.
Note: You can achieve similar results by using the
selectiononNavigationView.
Deep link navigation with tab view
Now that you understand how to switch TabView programmatically. You’ll use this to navigate your way with a deep link.
Select the PetSave target and click info. Then unfold the URL Types.
Click the small +, add petsave to the URL Schemes and press return.
Now, open the PetSaveTabType.swift and add the following method to the enum:
static func deepLinkType(url: URL) -> PetSaveTabType {
if url.scheme == "petsave" {
switch url.host {
case "nearYou":
return .nearYou
case "search":
return .search
default:
return .nearYou
}
}
return .nearYou
}
This checks if the scheme is petsave. Then it checks if the host is either nearYou or search and returns the respective type. To keep things simple, the default type is .nearYou if the scheme doesn’t match petsave.
Then, open ContentView.swift and add the following modifier at the end of the TabView:
// 1
.onOpenURL { url in
// 2
let type = PetSaveTabType.deepLinkType(url: url)
// 3
self.tabNavigator.switchTab(to: type)
}
Here, you:
- Receive the opened url.
- Get the correct tab type using
url. - Call
switchTab(_:)to present the right tab depending ontype.
Build and run. In the simulator, tap Home icon and open Safari browser. Type petsave://search in the browser and press return. You’ll see the following alert.
On the alert, tap Open, which takes you to the app’s search view.
Go back to the browser, type petsave://nearYou and tap go. When you see the alert, tap Open, and you’ll see:
Woooohoooo! The deep link works, and so does the programmatic switching of views. You did a great job!
Key points
- You can use a router to decouple the code and do navigation.
- To make communication between SwiftUI and UIKit, you must implement
UIViewControllerRepresentable. - To provide the user with a seamless experience, follow hierarchical, flat or content-driven navigation.
- You can pass the view specific data using
@Stateand@Binding. - You can pass custom data types using
@StateObjectand@ObservedObject. - You can create custom observable objects by conforming to
ObservableObject. Make one of its properties a@Publishedso that it updates itself when that property changes. - You can use
@Environmentto read the system objects injected using.environment(). -
@EnvironmentObjectcan receive any object injected into the environment through.environmentObject(_).
Where to go from here?
That brings the end of this chapter. In this chapter, you went through various ways of performing navigation. Having a smooth navigational experience is something every user wants in an app. So when implementing navigation, you should always be mindful of that.
Moreover, go and check out our tutorial on SwiftUI navigation. You can also read the navigation tutorial from Apple. Also, you can read more in detail from Apple about the Types of Navigation.
In the next chapter, you’re going to add some more fun to the app. You’ll learn about adding animations and custom controls to the app’s UI while also abiding by Apple’s Human interface guidelines.