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()
}
}
}
}
caution
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 navigationDestination
s 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.