The Navigation API
Apple recently released their improved method of navigation in SwiftUI, which was announced at WWDC 2022.
Whilst the API is very likely to change and evolve before the full release, this article looks at how it can be implemented to handle some trickier navigation tasks in native SwiftUI.
Navigation in SwiftUI so far
Programmatic navigation and deep linking has been more challenging for newer developers, such as myself, to adopt in purely SwiftUI projects.
I have mostly relied on NavigationView
and NavigationLink
to perform the navigation within my apps, which is functional but pretty basic and limited in nature.
With the introduction of NavigationStack
, NavigationPath
, navigationDestination
modifier and adjustments to NavigationLink
, more complex navigation tasks such as deep linking and popping multiple views/popping to root become easier to implement purely in SwiftUI.
Holiday Destinations - Example
In this sample project, I have a few screens that can be navigated between.
-
DestinationListView
- A view containing a list of the available holiday destinations -
DestinationDetailView
- A view that provides more detail about the destination that was selected from the above list view -
DestinationInfoView
/DestinationBookingView
- Views that would allow the user to view more information or book the destination. These can be accessed from the detail view via corresponding buttons
Project Setup
To begin, I created a basic data model, DestinationModel
. This must conform to Hashable to be used with the navigationDestination view modifier later. Alongside this model, I created a mockDestinations
property that stores some sample data, which can be viewed in the full file below.
struct DestinationModel: Hashable, Identifiable {
let id = UUID()
let location: String
let pricePerPerson: Double
let description: String
static let mockDestinations: [DestinationModel] = [
...
]
}
Full file: DestinationModel.swift
(note: if you have a more complex data model with properties that do not conform to Hashable, you will need to add a hash(into:) method manually)
Storing Navigation State
In order to keep track of the navigation routes that our app has taken, it is best to abstract the storage of this information as a navigation path outside of the views.
I created an ObserverableObject
class Router
to do just this.
final class Router: ObservableObject {
@Published var navPath: NavigationPath = .init()
}
Providing Access to the Navigation State in a View
Then, instantiate this in the root view of the app as a StateObject
, which can then be injected to the child views as an environmentObject
. This enables us to gain access to the properties of our Router
object in any view.
@main
struct NavigationStackApp: App {
@StateObject private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.navPath) {
DestinationListView()
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(router)
}
}
}
(note: We are also wrapping our root view in the new NavigationStack
, so that we can use the Navigation API in all child views using the @EnvironmentObject
property wrapper)
struct DestinationListView: View {
@EnvironmentObject var router: Router
var body: some View {
...
}
}
Passing a value to NavigationLink
Previously when using NavigationLink
, we would typically pass a destination
and label
as parameters for a new view.
Now, we can use a new completion that passes a value
and label
. The value must conform to Hashable, which our model already does. We can use the destination that is being iterated on in our list, to pass into the NavigationLink
as a value
. We can then use a closure to provide the label
for the NavigationLink, just as before.
struct DestinationListView: View {
@EnvironmentObject var router: Router
var body: some View {
List {
ForEach(DestinationModel.mockDestinations) { destination in
NavigationLink(value: destination) {
VStack (alignment: .leading, spacing: 10) {
Text(destination.location)
.font(.headline)
Text("\(destination.pricePerPerson, format: .currency(code: "USD"))")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
}
}
Setting the destination for NavigationLink
We can then add the navigationDestination
modifier to the outside of our list, and provide the type of model that we have declared in our NavigationLink(value: _)
. This allows our NavigationPath
to know the type of element that is being appended to the stack, and since it is hashable, it can also be identified and compared by it's hashValue.
For each destination we pass to the navigationDestination, we can then provide the view to be navigated to, as well as pass in any properties that view may require.
.navigationDestination(for: DestinationModel.self) { destination in
DestinationDetailView(destination: destination)
}
Full file: DestinationListView.swift
Destination Detail View
This is the view that will be shown when a destination in the previous list view was tapped.
struct DestinationDetailView: View {
@EnvironmentObject var router: Router
let destination: DestinationModel
var body: some View {
VStack(alignment: .center, spacing: 50) {
Text(destination.location)
.font(.title)
Text(destination.description)
.font(.body)
VStack {
Text("Price per Person")
Text("\(destination.pricePerPerson, format: .currency(code: "USD"))")
.bold()
}
}
.padding(.horizontal)
}
}
struct DestinationDetailView_Previews: PreviewProvider {
static var previews: some View {
DestinationDetailView(destination: DestinationModel.mockDestinations.first!)
.environmentObject(Router())
}
}
The two buttons both toggle a sheet to show a different view (DestinationBookingView
or DestinationInfoView
). We can add a property to our router to allow us to programmatically toggle the sheet.
@Published var showSheet = false
We can also start leveraging the power of Swift to allow us to switch our navigation path's route. By declaring an enum, and using @ViewBuilder
, we can be more dynamic in which sheet is shown.
enum Router {
case info
case booking
@ViewBuilder
var view: some View {
switch self {
case .info:
DestinationInfoView()
case .booking:
DestinationBookingView()
}
}
}
var nextRoute: Router = .booking
By adding the property nextRoute
, we are able to change which view will be displayed on the sheet on the fly.
Full file: Router.swift
In our DestinationDetailView
file, we can now alter the properties on the router dependent on which button has been pressed.
Button("Info") {
router.nextRoute = .info
router.showSheet.toggle()
}
Button("Book") {
router.nextRoute = .booking
router.showSheet.toggle()
}
Remember to also create a sheet using the sheet
modifier. Bind this to the Router's showSheet
property, and the view that will be displayed will be the returned value of the @ViewBuilder
we used in our Router class.
.sheet(isPresented: $router.showSheet) {
router.nextRoute.view
}
Full file: DestinationDetailView.swift
Popping to the root view
So far, we haven't navigated too deep into our app, but the following will demonstrate how you can pop to the root view of your NavigationPath
.
(note: the DestinationBookingView
and DestinationInfoView
contain almost identical code, as they are placeholder views)
By reinitialising our Router's navPath
property, we are essentially saying that the NavigationPath
is empty, and that are stack should contain no additional views.
Button("Pop to root") {
router.showSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
router.navPath = .init()
}
}
This will dismiss the sheet, and after a short delay, navigate back to the root view, and clears any additional data in the router.navPath
property.
DestinationBookingView.swift
DestinationInfoView.swift
Deeplinking and further implementations
With the setup used above, we are able to dynamically switch the path of the navigation router, as well as programmatically pass particular data models when navigating.
The below is an example of navigating to a random element in the array of mock destinations, and then opening a specific sheet. We can also detmine which sheet by adjusting the nextRoute
property of Router.
In DestinationListView
:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
router.navPath.append(DestinationModel.mockDestinations.randomElement()!)
router.nextRoute = .info
}
}
In DestinationDetailView
:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
router.showSheet.toggle()
}
}
You can also programmatically navigate using the array's index of the element you wish to pass. For example, you could navigate to the first destination that contains the following for the location
property: "Costa Rica, Central America".
In DestinationDetailView
:
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
guard let index = DestinationModel.mockDestinations.firstIndex(where: { $0.location == "Costa Rica, Central America" }) else { return }
router.navPath.append(DestinationModel.mockDestinations[index])
router.nextRoute = .info
}
}
Finishing Thoughts
You could continue further, and build up a variety of different routes your app could take by creating a Route
object, and then passing an array of this to your router to handle. However, I'm not quite there with the implementation just yet myself!
Any feedback/improvements/mistakes to correct is always appreciated.
The full repo can be found here.
Top comments (0)