Published Jul 26. 2023 | Marcel Kulina

Modularised Navigation in SwiftUI - An Enum-based approach

| Utilising the new NavigationStack with the power of enums in a modular, expandable way.

Introduction

Under iOS 16 SwiftUI finally allows us to properly encapsulate navigation without being forced to pass a ton of bindings or using NavigationLink directly in UI.

One thing that always introduces additional challenges, though, is modularisation. In a best-case scenario, every module functions independently and has zero dependencies on other modules (in the same layer).

While it is possible to achieve that, there is always the need to navigate from one module to the other. One approach that elegantly solves this issue is the use of enums for internal and external navigation.

Concept

The App (or parts of it) are bundled within a single router. The entry point (App/Root View/TabViews) creates and holds the NavigationPath and passes it to a NavigationStack. Since NavigationPath is a reference type, we can then create our main router and pass to it that very same path. Now all the main router needs are some operations for pushing and popping views.

As for the modules, each module contains a custom router that handles the module's internal routing. These routers get a reference to the main router, enabling them to modify the initial NavigationPath by calling the main router's methods.

To modify the view stack, each module has an enum that contains all the possible routes. That enum is processed by the view modifier view.navigationDestination().

Every module manages navigation via an internal enum that is mapped via the view modifier view.navigationDestination(). Since every module's router still calls into the main router, the NavigationPath is altered, without the routers having an actual reference to the path itself.

The last tricky part is getting the modules to navigate between each other. They never actually do that. Instead, they call the same methods from the main router as if they were internally navigating, but this time they pass another enum value. This enum handles exit points for the module. Technically, there could be multiple exits, all with different associated values, to make distinctions and data transfers between modules straightforwardly.

The actual app maps each exit enum for every module via view.navigationDestination(), and, since the navigation happens on app-level this time, no module ever knows to which module it is navigating, making modules completely independent from each other.

Implementation

Now that the general idea has been outlined, let's move on to the actual implementation.

App Level

The first thing that needs to be done is the global router that all modules interact with.

App Router

public class AppRouter: ObservableObject {
    // Restriction: Has to be private to the NavigationStack can bind to it
    @Published var path: NavigationPath

    init(with path: NavigationPath) {
        self.path = path
    }

    func navigate(to destination: any Hashable) {
        path.append(destination)
    }

    func pop() {
        path.removeLast()
    }
}

tip

As stated before, the main entry router does not necessarily have to be the app itself but could be multiple routers for each tab in a tab view or something similar. The concept remains the same.

Module Level

Now that the main router is defined, we can move on to the modules. As mentioned, each module has its own router, but it also makes sense to have some base type for the module router.

Module Router Protocol

protocol ModuleRouter {
    var appRouter: AppRouter { get }
}

Example Module Router Implementation

// Enum for internal routes
enum DashboardRoute: Hashable {
    case details
}

// Enum for external routes (exits from this module)
enum DashboardExit: Hashable {
    case logout
    case settings
}

// The module router
class DashboardRouter: ModuleRouter {
    var appRouter: AppRouter

    init(with appRouter: AppRouter) {
        self.appRouter = appRouter
    }

    func navigate(to target: DashboardRoute) {
        appRouter.navigate(to: target)
    }

    func pop() {
        appRouter.pop()
    }
}

For our main app to not make any decisions regarding the routing of our module, we define an extension of View that maps the module's routes enum to the specific target views. This modifier can then easily be called from the main app, without knowing any detail about the actual navigation and views.

View Extension

// Custom view modifier for routing of this module
public extension View {
    public func withDashboardRoutes(): some View {
        self.navigationDestination(for: DashboardRoute.self) { destination in
            switch destination {
                // Handle navigation logic for each route of this module
                case .details:
                    DetailView()
                ...
            }
        }
    }
}

Now that we have the modules set up, we can bind their navigation in our actual app.

import Dashboard
import OtherModule
import EvenAnotherModule
@main
struct Application: App {
    @ObservedObject var router: AppRouter
    
    init() {
        // In an actual app this should come from a DI container.
        self.router = AppRouter()
    }
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                // Again, in an actual app this should come from a DI container.
                DashboardView(router: DashboardRouter(with: router)) 
                    .withDashboardRoutes()
                    .withOtherModuleRoutes()
                    .withEvenAnotherModuleRoutes()
            }
        }
    }
}

warning

Don't forget to apply your route modifiers in your main app.

Cross-Module Navigation

Now that the modules can navigate internally and all navigation is bound to the app, all that is left is navigation between modules. For that, we simply add additional navigationDestinations for each module.

import Dashboard
import OtherModule
import EvenAnotherModule

@main
struct Application: App {
    @ObservedObject var router: AppRouter
    
    init() {
        // In an actual app this should come from a DI container.
        self.router = AppRouter()
    }
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                // Again, in an actual app this should come from a DI container.
                DashboardView(router: DashboardRouter(with: router)) 
                    .withDashboardRoutes()
                    .withOtherModuleRoutes()
                    .withEvenAnotherModuleRoutes()
                    .navigationDestination(for: DashboardExit.self) { destination in 
                        switch destination {
                            case .logout:
                                LoginView()
                            case .settings:
                                SettingsView()
                        }
                    }
            }
        }
    }
}

Thoughts

Thanks to NavigationStack and NavigationPath SwiftUI navigation has gotten a lot better. Enums offer an excellent way to use the new navigation, but cross-module navigation also comes with some trade-offs. This concept works great in my opinion but surely isn't the only way to go. There are, of course, a few things missing - such as sheets, modals etc. - but the barebone routing should be solid for most cases.

Where To Go From Here

  • How could modules completely hide their Views from the App -> Routing entries that only return some View?
  • How could sheets and modules be introduced to this architecture?

Thank you for reading.