DEV Community

Duncan Kent
Duncan Kent

Posted on • Edited on

The new NavigationStack & NavigationPath for SwiftUI

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.

  1. DestinationListView - A view containing a list of the available holiday destinations
  2. DestinationDetailView - A view that provides more detail about the destination that was selected from the above list view
  3. 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] = [
        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

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()

}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

(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 {
      ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
Enter fullscreen mode Exit fullscreen mode

Image description

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.

Image description

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())
    }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

Button("Pop to root") {
    router.showSheet = false

    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        router.navPath = .init()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

In DestinationDetailView:

.onAppear {
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        router.showSheet.toggle()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)