At first glance, navigation in SwiftUI seems pretty straightforward. With a combination of NavigationView, NavigationLink and .sheet, we can quickly link views together in ways that resemble UIKit.

But when an app grows more than a handful of views, or you find yourself in situations where you need a more flexible method of triggering navigation, you’ll quickly discover that the framework falls short.

This is largely due to navigation being tightly coupled and hardcoded within its views, becoming increasingly more difficult to:

Advanced architectural patterns usually advocate for keeping navigation logic independent of their views, so what approach can we take to decouple navigation logic? Let's start with a little abstraction

Abstracting navigation

If we were to strip out any infrastructure or UI frameworks from the navigation experience, we could map it out into a flow diagram like the following:

Abstracting navigation

When there is an intent to navigate, we determine which direction we would like to go. Are we returning to a previous experience (navigating backward)? Or are we moving onto the next step of the flow (navigating forward)? Based on the direction, the intent will either result in a new destination being presented or some form of a dismissal

Lets try and translate the above into relevant entities:

enum NavigationDirection { case back case forward(destination: NavigationDestination, style: NavigationStyle) }

A navigation intent will either be a forward or backward motion. In the case of going back, there isn't an obvious need to include any associative data. However when it comes to advancing to another screen or experience, a navigable entity should know it's destination

enum NavigationDestination { case addTodo case todoDetails(todo: Todo) }

The NavigationDestination enum is where we'd map out all the possible destinations (screens) of our app. We can leverage associative types here by including any relevant information a destination needs to build its views as demonstrated with the todoDetails case.

enum NavigationStyle { case push case present }

It's fairly common a platform will have more than way of presenting a view. The NavigationStyle entity will provide a means of specifying the intended presentations style.

With these entities in place, we now have a good representation of navigation that does not include any framework specific code.

Presentation

iven the reactive functional nature of SwiftUI, it's likely that our view models will be observable objects that we can subscribe to and receive updates. With that in mind, a good means of communicating navigation intents as they occur would be via a published property:

import Combine class ViewModel: ObservableObject { @Published var navigationDirection: NavigationDirection? }

All changes to the published property by the view model will be communicated to an interested View:

class ViewModel: ObservableObject { @Published var navigationDirection: NavigationDirection? // .. func todoTapped(_ todo: Todo) { navigationDirection = .forward(destination: .todoDetails(todo: todo), style: .push) } // .. }

With this approach, our view models have the flexibility to trigger navigation updates in a variety of ways, even during async operations:

class ViewModel: ObservableObject { private var todoRepository = TodoRepository.shared private var cancellables = Set() @Published var navigationDirection: NavigationDirection? @Published var loading: Bool = false func addTodo(title: String) { loading = true todoRepository.addTodo(title: title) .receive(on: DispatchQueue.main) .sink { [unowned self] _ in navigationDirection = .back } .store(in: &cancellables) } // .. }

That's pretty neat but how will our View be able to process a NavigationDirection update?

The final layer

ViewModifiers are great in the sense that not only can they modify the look and feel of our views, but they can also add functionality in a reusable composable way. Leveraging this functionality, we can create a modifier to provide any view with the capability to respond to a NavigationDirection update:

struct NavigationHandler: ViewModifier { @Binding var navigationDirection: NavigationDirection? var onDismiss: ((NavigationDestination) -> Void)? @State private var destination: NavigationDestination? @State private var sheetActive = false @State private var linkActive = false @Environment(\.presentationMode) var presentation let viewFactory = ViewFactory() func body(content: Content) -> some View { content .background( EmptyView() .sheet(isPresented: $sheetActive, onDismiss: { if let destination = destination { onDismiss?(destination) } }) { buildDestination(destination) } ) .background( NavigationLink(destination: buildDestination(destination), isActive: $linkActive) { EmptyView() } ) .onChange(of: navigationDirection, perform: { direction in switch direction { case .forward(let destination, let style): self.destination = destination switch style { case .present: sheetActive = true case .push: linkActive = true } case .back: presentation.wrappedValue.dismiss() case .none: break } navigationDirection = nil }) } @ViewBuilder private func buildDestination(_ destination: NavigationDestination?) -> some View { if let destination = destination { viewFactory.makeView(destination) } else { EmptyView() } } } Above, we put together a modifier that attaches a sheet and an invisible NavigationLink to the relevant view. via the onChange method, we subscribe to changes from from the binding navigationDirection property and based on the direction we either:

Present or push the destination depending on the supplied style when moving forward or Dismiss the current view regardless of its presentation style using the presentationMode environment value when moving backward Once we set the destination and toggle the state of either presentation styles, the modifier will turn to our buildDestination() method to build the relevant view associated with the destination. In the code example above, we are using a factory approach to provide the view.

Let's make a quick extension to make our modifier easier to to apply to our views: extension View { func handleNavigation(_ navigationDirection: Binding, onDismiss: ((NavigationDestination) -> Void)? = nil) -> some View { self.modifier(NavigationHandler(navigationDirection: navigationDirection, onDismiss: onDismiss)) } }

and now an example of view that makes use of the modifier:

struct TodoListView: View { @StateObject private var viewModel = ViewModel() var body: some View { NavigationView { Text("This view will be able to process a navigation update") .handleNavigation($viewModel.navigationDirection) } } }

Benefits to this approach:

Navigation logic is separated from our view, making it a lot easier to change user flows or reuse our views in different areas of the app. With the view model being responsible for orchestrating navigation, we can do flexible things like trigger a navigation after a network request completes We can now test navigation interactions via our view models' unit tests An important note: We still need to manually ensure that a view's hierarchy has access to a NavigationView when using the push presentation style. This takes us away from true abstraction nirvana, but despite this minor setback we find ourselves in a much more flexible state.

Conclusion

SwiftUI's approach to navigation can quickly get challenging and limiting when it comes to programmatic navigation or building modular user flows. By abstracting and separating navigation logic from our views, we can increase the reusability, modularity and maintainability of our code base.