Skip to content
Banner image

SwiftUI Programmatic Routing with NavigationStack

Authored on December 25, 2023 by Aaron Christopher.

6 min read
--- views


After a long hiatus from releasing new blog content, I'm back with iOS content. I've gained valuable insights into iOS and Swift Programming at the Apple Developer Academy. Now, with the iOS 17 updates, we have a new Observable macro and Observable Framework. Additionally, the latest navigation system introduced in iOS 16, NavigationStack, is quite robust, and we'll leverage that to our advantage.

The usual implementation of NavigationStack is that you can put NavigationLink to allow user to navigate by pressing it.

struct ContentView: View { let products: [Product] var body: some View { NavigationStack { List(products) { product in NavigationLink(product.title) { ProductDetailView(product: product) } } .navigationTitle("Products") } } } struct ProductDetailView: View { let product: Product var body: some View { Text(product.title) .font(.title) .navigationTitle(product.title) } }

In the example above, we set up a basic master-detail setup. We plant the NavigationStack at the top of our view setup. Then, we list out messages, each serving as a link to its details screen. Notice, we're sticking with the old-school NavigationLink type, and it does the job just fine here.

I enjoy organizing my feature's navigation flow in one central location. That's why I typically use the Navigator pattern to manage navigation in a type-safe manner. Implementing the Navigator pattern with SwiftUI's new data-driven Navigation API is straightforward. Initially, we need to establish an enum type that outlines all the routes for our app, feature, or module.

enum Route: Hashable { case first case second case third case fourth }

Next up, we define the view based on the route we define earlier

struct Routes: View { let route: Route var body: some View { switch route { case .first: FirstView() case .second: SecondView() case .third: ThirdView() case .fourth: FourthView() } } }

After that, we should create a single controller for the navigation. We can do this by using a ViewModel. Remember when I said earlier that we can utilize the new Observable Framework? We will define the ViewModel by using the Observable macro.

@Observable class RouteViewModel { static var shared = RouteViewModel() var navPath = [Route]() { willSet { previousRoute = navPath.last } } private(set) var previousRoute: Route? var currentRoute: Route? { navPath.last } // MARK: - Append route to navigation path /// Append AKA go to next route func append(_ route: Route, before: (() -> Void)? = nil) { before?() navPath.append(route) } // MARK: - Pop route from navigation path /// Pop AKA return to previous view in the navigation stack func pop(before: (() -> Void)? = nil) { guard !navPath.isEmpty else { print("navPath is empty") return } before?() navPath.removeLast() } // MARK: - Pop multiple routes /// Return to previous view multiple times in the navigation stack func pop(_ count: Int, before: (() -> Void)? = nil) { guard navPath.count >= count else { print("count must not be greater than navPath.count") return } before?() navPath.removeLast(count) } // MARK: - Pop to root /// Back to root view func popToRoot(before: (() -> Void)? = nil) { before?() navPath.removeLast(navPath.count) } // MARK: - Append multiple routes /// Append multiple routes to navigation stack func append(_ routes: Route..., before: (() -> Void)? = nil) { before?() routes.forEach { route in navPath.append(route) } } }

Looks like pretty lengthy code, doesn't it? The navPath variable is the source of truth, storing the list of routes. It is an array of route and we can modify it like any other array. We've defined some methods there to assist us in navigating and modifying the navPath itself. This ViewModel will be used globally across the app, so we should create an Environment.

Defining the Environment

To define the navigate Environment, we can extend the EnvironmentValues from SwiftUI. But first, we need to define the EnvironmentKey with the defaultValue of the ViewModel itself.

struct NavigationEnvironmentKey: EnvironmentKey { static var defaultValue: RouteViewModel = .init() }

After that, we can extend the EnvironmentValues. I'm naming it navigate but you can use any other names as you like.

extension EnvironmentValues { var navigate: RouteViewModel { get { self[NavigationEnvironmentKey.self] } set { self[NavigationEnvironmentKey.self] = newValue } } }

Now we can use the environment globally. Next up we need to use the ViewModel in our NavigationStack. In this implementation we are going to do it in the App struct.

Implementing the NavigationStack

We must use Bindable macro instead of State because NavigationStack needs to bind the NavigationPath.

struct SwiftRoutingApp: App { @Bindable private var routeViewModel = RouteViewModel.shared var body: some Scene { WindowGroup { NavigationStack(path: $routeViewModel.navPath) { ContentView().navigationDestination(for: Route.self) { route in Routes(route: route) } }.environment(\.navigate, routeViewModel) } } }

We now have exactly one NavigationStack wrapping the ContentView, and we attach the navigationDestination view modifier and use our previously defined Routes struct to map the view inside of the NavigationStack. Don't forget to use the environment view modifier to be able to access the view model inside the children view.


You should note that in the previously defined Routes mapping, there are several views. Now, we will use it to demonstrate our new navigation system. The view itself isn't very complex but is sufficient for demonstration.

struct FirstView: View { @Environment(\.navigate) private var navigate var body: some View { Text("First View") Button("To second view") { navigate.append(.second) } } }
struct SecondView: View { @Environment(\.navigate) private var navigate var body: some View { Text("Second View") Button("To third view") { navigate.append(.third) } Button("Back to root") { navigate.popToRoot() } } }
struct ThirdView: View { @Environment(\.navigate) private var navigate var body: some View { Text("Third View") Button("To fourth view") { navigate.append(.fourth) } Button("Pop two view") { navigate.pop(2) } Button("Back to root") { navigate.popToRoot() } } }
struct FourthView: View { @Environment(\.navigate) private var navigate var body: some View { Text("Fourth View") Button("Pop") { navigate.pop() } Button("Pop to second view") { navigate.pop(2) } Button("Back to root") { navigate.popToRoot() } } }
struct ContentView: View { var body: some View { FirstView() } }

When you run the app, the first view will be presented. Press the button to navigate to the second view. Now, try interacting with all the buttons and see how robust it is. You don't need to use NavigationLink all over the place; just call the navigate method. You can also call this inside any functions or triggers.

Of course, the source code is available here on GitHub.


I'm thrilled to use the new data-driven Navigation API in SwiftUI. I hope you enjoy the post. Feel free to contact me and ask your questions related to this post. Thanks for reading, and see you in the next blog!