Abstracting Navigation in SwiftUI

Subscribe to my newsletter and never miss my upcoming articles

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:

  • Reuse views in different flows
  • Adjust or make changes to our flows as we iterate
  • Trigger navigation outside the the scope of the view (i.e view models, network requests)

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:

untitled@2x (2).png

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(id: String)
}

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

Given 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 publisher:

import Combine

protocol Navigable {
    var navigationPublisher: AnyPublisher<NavigationDirection, Never> { get }
}

implementing the above protocol will allow a view model, for instance, to provide a means of communicating navigation updates to an interested View:

class ViewModel: ObservableObject, Navigable {
    private let navigationSubject = PassthroughSubject<NavigationDirection, Never>()
    var navigationPublisher: AnyPublisher<NavigationDirection, Never> {
        navigationSubject
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }

    func todoTapped(id: String) {
        navigationSubject.send(.forward(target: .todoDetails(id: id), style: .push))
    }

    // ....
}

Above we use a PassthroughSubject to channel navigation updates. Whenever logic in our view model results in a navigation intent, the update is sent to our subject which then broadcasts it to any Views that might be listening. We ensure the update is communicated on the main thread via the .receive(on:) operator. As a result, an interested view would not need to worry about invoking any UI updates on the wrong thread.

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, Navigable {
    // ..
    func addTodo(title: String) {
        viewState = .loading
        todoRepository.addTodo(title: title)
            .sink { [weak self] completion in
                switch completion {
                case .failure:
                    self?.viewState = .error
                case .finished:
                    break
                }
            } receiveValue: { [weak self] todo in
                self?.navigationSubject.send(.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. Similar to how we make our view models navigable via a protocol, we can create a modifier to provide any view with the capability to respond to a NavigationDirection update:

struct NavigationHandler: ViewModifier {
    let navigationPublisher: AnyPublisher<NavigationDirection, Never>
    @State
    private var destination: NavigationDestination?
    @State
    private var sheetActive = false
    @State
    private var linkActive = false
    @Environment(\.presentationMode) var presentation
    let viewFactory: ViewFactory = ViewFactory()
    func body(content: Content) -> some View {
        content
            .sheet(isPresented: $sheetActive) {
                buildDestination()
            }
            .background(
                NavigationLink(destination: buildDestination(), isActive: $linkActive) {
                    EmptyView()
                }
            )
            .onReceive(navigationPublisher) { 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()
                }
            }
    }

    @ViewBuilder
    private func buildDestination() -> some View {
        if let destination = destination {
            viewFactory.makeView(destination: destination)
        } else {
            EmptyView()
        }
    }
}

Above, we put together a modifier that attaches a sheet and an invisible NavigationLink to the relevant view. via the onReceive method, we subscribe to updates from the navigation publisher 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(_ publisher: AnyPublisher<NavigationDirection, Never>) -> some View {
        self.modifier(NavigationHandler(navigationPublisher: publisher))
    }
}

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.navigationPublisher)
        }
    }
}

And there we have it, a somewhat abstracted approach to handling navigation in our SwiftUI apps.

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.

I hope you enjoyed this article and found it useful. If you have any questions, comments, or feedback i'd love to hear them. Contact me or follow me on Twitter

No Comments Yet