How to implement scalable and testable SwiftUI navigation?

Although SwiftUI is a great framework, it’s not without its limitations. If you’ve ever had to implement a complex application, utilizing branching navigation flows, you probably know what I am talking about. Yes, navigation components in SwiftUI have been a constant source of headache… 

An iOS 16 addition, the NavigationStack, helps a lot, but does not solve all of our problems. E.g. we can manage its state using the NavigationPath, but it does not allow us to iterate through it and see which views are currently displayed. We don’t really need that information when executing simple “fire-and-forget” navigation, e.g. using NavigationLinks. But what if we really need to verify which views are on the display? Navigating back to a particular view can be a perfect example here. 

Fortunately, there is a way to achieve almost UIKit-level control over the navigation stack. All we need to do is to take what SwiftUI gives us and build upon it. In this post, we’ll try to implement SwiftUI navigation Router, fully integrated with the NavigationStack. Please bear in mind that at least some commercial experience with SwiftUI would be beneficial to fully appreciate the content below. That being said, strap in and let’s go!

SwiftUI Navigation Router - the main concepts.

This approach to navigation in SwiftUI is based on 2 components, tightly bound to each other:

  • A “home” view – a View that embeds the NavigationStack and implements view modifiers allowing SwiftUI to build and show a desired view, in a specific way (e.g. as a popup)
  • A Router – a single source of truth about the navigation; Provides precise information which views are pushed on the NavigationStack, and is there a popup / alert presented at the moment.
Always two there are - How to implement scalable and testable SwiftUI navigation?

Let’s take a look at the view first (full listing):

struct SwiftUIRouterHomeView<Router: SwiftUINavigationRouter>: View {
    @ObservedObject var router: Router

    var body: some View {
        NavigationStack(
            //  Create custom binding to the navigation path.
        ) {
            RootView()
            .navigationDestination(for: NavigationRoute.self) { route in
                //  Handle views to be pushed on the navigation stack here.
            }
            .sheet(item: $router.presentedPopup) { _ in
                 //  Handle different popups here
            }
            .alert(
                presenting: $router.presentedAlert,
                confirmationActionCallback: { alertRoute in
                    //  Handling app alert confirmation action:
                }
            )
        }
    }
}

As you can see, the NavigationStack is a centrepiece of the home view. It’s initiated with a binding to the Router’s navigationStack property. The binding works both ways. Not only does the NavigationStack require an access to data structure representing views to display, but it can also update it. Think about the situation when the user taps on the “back” button on the navigation bar. 

The NavigationStack must embed some kind of “starter” view. It could be an empty one if you wish. To enable pushing and popping views, we need to add a navigationDestination modifier to that view. If we wish to also display popups and alerts, we should define sheet and alert view modifiers respectively. 

All these view modifiers rely on 2-way binding with the navigation data provided by the Router. So let’s see now how we can manage the navigation stack with this component:

protocol SwiftUINavigationRouter: AnyObject, ObservableObject {
    /// A navigation stack: (1)
    var navigationRoute: NavigationRoute? { get }
    var navigationStack: [NavigationRoute] { get }

    /// A currently presented popup. (2)
    var presentedPopup: PopupRoute? { get set }
    var presentedPopupPublished: Published<PopupRoute?> { get }
    var presentedPopupPublisher: Published<PopupRoute?>.Publisher { get }

    /// A currently presented alert. (3)
    var presentedAlert: AlertRoute? { get set }
    var presentedAlertPublished: Published<AlertRoute?> { get }
    var presentedAlertPublisher: Published<AlertRoute?>.Publisher { get }

    /// Pushing and popping views: (A)
    func set(navigationStack: [NavigationRoute])
    func push(route: NavigationRoute)
    func pop()
    func popAll()

    /// Presenting & dismissing popups: (B)
    func present(popup: PopupRoute)
    func dismiss()

    /// Showing and hiding alerts. (C)
    func show(alert: AlertRoute)
    func hideCurrentAlert()
}

As you can see, the Router can be cleanly divided into 3 separate sections:

  • A section handling push & pop navigation (1) & (A)
  • A section presenting and dismissing popups (2) & (B)
  • A section showing and hiding alerts (3) & (C)

Each section contains a portion of the navigation state of the application. If we need to e.g. show a popup, we need to assign a proper value to the presentedPopup. As you can guess, that value is a particular case of an enumeration describing popups. The only condition is that the enumeration to conform to Identifiable and Hashable protocols:

enum PopupRoute: Hashable, Codable, Identifiable {
    case addAsset
    case appInfo 

    var id: Int {
        hashValue
    }
}

Similarly, push & pop views and alerts can be modelled using similar enumerations. 

TLDR: The Router is the single source of truth when it comes to the state of the app navigation. It exposes properties that Home View can be bound to. Each time these properties are changed programmatically, SwiftUI redraws the NavigationStack content to match the requested output. In addition, every time the user e.g. dismisses a popup or taps a “back” button, the binding will work the other way. The relevant properties in the Router would be immediately set to the proper values.

That’s the true beauty and strength of declarative navigation

How does it work from within?

Let’s start with something simple: push & pop navigation we all know.
In order to make our Router communicate with the NavigationStack we need to initiate it with a custom binding (1):

NavigationStack(
    path: .init(
        //  (1) Creating custom binding between the Router and the NavigationStack:
        get: {
            router.navigationStack
        },
        set: { stack in
            router.set(navigationStack: stack)
        })
) {
    MyInitialView()
        .navigationDestination(for: NavigationRoute.self) { route in
            //  (2) Building views represented by NavigationRoute
            switch route {
            case let .editAsset(id):
                makeEditAssetView(id: id)
            case let .assetDetails(id):
                makeAssetDetailsView(id: id)
            ...
            }
        }
}

The Router’s navigationStack property is nothing more than a collection of NavigationRoute enum cases. This binding links that collection with a SwiftUI view, capable of generating and showing subviews representing a particular NavigationRoute. To make this work, we need to “tell” the NavigationStack how to build these subviews (2)

How about showing popups
It’s even simpler. All we need to do is to add sheet view modifier to our root view:

.sheet(item: $router.presentedPopup) { _ in
    if let $popup = Binding($router.presentedPopup) {
        switch $popup.wrappedValue {
        case .appInfo:
            makeAppInfoView()
        case .addAsset:
            makeAddAssetView()
        }
    }
}

As you can see, we’re creating another 2-way binding here. This time, whenever the presentedPopup property is set in the Router, the NavigationStack will create the desired view and present it as a popup. When the property is set to nil, the popup will be dismissed. Alternatively, the user can dismiss the popup using the drag down gesture. The binding would then automatically nullify the presentedPopup property on the Router

And how is it implemented in the Router? Let’s take a look:

protocol SwiftUINavigationRouter: AnyObject, ObservableObject {

    /// A currently presented popup.
    var presentedPopup: PopupRoute? { get set }
    var presentedPopupPublished: Published<PopupRoute?> { get }
    var presentedPopupPublisher: Published<PopupRoute?>.Publisher { get }

    /// Presents provided popup as sheet.
    ///
    /// - Parameter popup: a popup to present.
    func present(popup: PopupRoute)

    /// Dismisses current popup.
    func dismiss()
}

Again, nothing to see here really – just a published property representing opened popup, and a navigation API to manage it.

And what if I wanted to show an alert? Let’s see:

.alert(
    presenting: $router.presentedAlert,
    confirmationActionCallback: { alertRoute in
        //  Handling app alert confirmation action:
        switch alertRoute {
        case let .deleteAsset(assetId, _):
            viewModel.removeAssetFromFavourites(id: assetId)
        }
    }
)

Wait a minute… It does not look like any alert view modifier I’ve seen. And you are absolutely right. It’s a custom view modifier, wrapping the original SwiftUI version:

extension View {

    func alert<T>(
        presenting data: Binding<T?>,
        confirmationActionCallback: @escaping (_ alert: T) -> Void,
        cancellationActionCallback: ((_ alert: T) -> Void)? = nil
    ) -> some View where T: AlertRoutePresentable {
        let title = data.wrappedValue?.title ?? ""
        let message = data.wrappedValue?.message ?? ""
        let confirmationActionTitle = data.wrappedValue?.confirmationActionText ?? "Confirm"
        let cancellationActionTitle = data.wrappedValue?.cancellationActionText ?? "Cancel"
        return alert(
            Text(title),
            isPresented: data.isPresent(),
            presenting: data.wrappedValue,
            actions: { alert in
                Button(role: .destructive) {
                    confirmationActionCallback(alert)
                } label: {
                    Text(confirmationActionTitle)
                }
                Button(role: .cancel) {
                    cancellationActionCallback?(alert)
                } label: {
                    Text(cancellationActionTitle)
                }
            },
            message: { _ in
                Text(message)
            }
        )
    }
}

As you can see, the original alert view modifier has… 2 sources of truth: a isPresented property (showing or hiding an alert) and a presenting property (providing data for the alert). In theory then, we could have a situation when an alert is shown, but has no data to display… To remedy that, we can create a custom modifier using a single source of truth: binding the enumeration containing data to display. This way, whenever that property is set, an alert will automatically be shown. Let’s take a quick look how it’s implemented in the Router (full listing):

final class DefaultSwiftUINavigationRouter: SwiftUINavigationRouter {
    @Published var presentedAlert: AlertRoute? = nil
    var presentedAlertPublished: Published<AlertRoute?> { _presentedAlert }
    var presentedAlertPublisher: Published<AlertRoute?>.Publisher { $presentedAlert }

    func show(alert: AlertRoute) {
        presentedAlert = alert
    }

    func hideCurrentAlert() {
        presentedAlert = nil
    }
}

Clean and readable – just what the doctor ordered. 

How can I use it?

Whichever way you prefer 😉 Honestly, the Router can be passed to both: business logics (e.g. ViewModel) and views. Let’s start with the latter:

struct MyView<Router: SwiftUINavigationRouter>: View {
    @ObservedObject var router: Router

    var body: some View {

        

        HStack {
            Button {
                router.push(route: .assetDetails(assetId))
            } label: {
                Image(systemName: "pencil.line")
            }
        }

        

    }
}

As you can see, making a view generic over a Router protocol, allows us to operate on the navigation stack from within that view. As you can surely remember, we used the same technique to bind a ViewModel with a view as well.

As tempting as this approach might look, there is a better place to inject the Router and execute navigation commands. In my humble opinion, Views should be kept as simple as possible to preserve readability, testability and reusability. Requesting change on the navigation stack is arguably a part of the business logic and should be handled e.g. by a ViewModel:

final class SwiftUIRouterAssetDetailsViewModel: AssetDetailsViewModel {
    @Published var viewState = AssetDetailsViewState.loading
    
    private let router: any SwiftUINavigationRouter
    

    init(
        assetId: String,

        router: any SwiftUINavigationRouter
    ) {
        
        self.router = router
        viewState = .loading
    }

    func edit(asset assetID: String) {
        router.push(route: .editAsset(assetID))
    }

    

}

In truth, you can pass the Router to virtually any other component dealing with app business logic: remote notifications handler, deep links handler, user login manager, etc. This should allow you e.g. to bring the user back to the login screen should an access token expire.

Summary

The NavigationStack component introduced in iOS 16 has changed the way we can implement navigation in SwiftUI apps. In order to conveniently operate on that stack, we required another component, capable of translating business-level requests to low-level navigation commands. Enter the Router. By binding the two together, we’ve managed to obtain almost a UINavigationController level of control over the navigation stack, as well as maintain the declarative nature of the navigation API. Let’s see what else we can tell about his approach:

The Good:

  • Precise:
    You can precisely define when and how to show a given view.

  • Scalable:
    Simply add new cases to enumerations representing Route, Popup or Alert. Just remember to also provide proper @ViewBuilder code to tell SwiftUI how to construct your view 😉

  • Stateful:
    All enums representing view, popups and alerts are Hashable. This means we can encode them and persist the resulting data between the application launches. This way we can save and restore the entire app navigation stack!

  • Testable:
    Although SwiftUI views are notoriously difficult to test, even the root application view can be fully covered with snapshot tests. There is one caveat though – SwiftUI alerts and popups are rendered on a separate layer then the base view. This means that, unless the view is added to the active UIWindow, a popup or alert will not appear. I’ll be covering this topic in one of the next posts, so stay tuned! 

The Bad:

  • iOS 16+ only:
    The NavigationStack component was introduced in iOS 16 and Apple does not plan to retro-fit it into older iOS versions.

  • Tight coupling between the Router and the HomeView:
    As you were able to see, neither the Router, nor the main app view can function without each other. This causes tight coupling between them and makes their code almost not reusable.

  • There can be only one NavigationStack:
    You cannot show a view hosting a NavigationStack inside another view, also hosting NavigationStack. Fortunately, this limitation does not seem to affect NavigationStack presented as a popup… The question remains: is it a conscious decision by Apple, or simply an oversight

The Ugly:

  • Complex and messy @ViewBuilders:
    The more views the application has, the more messy and complex the @ViewBuilder code creating them. At some point, it might be a good idea to extract the creation of these views to a dedicated factory.

So, where can we safely use this approach to navigation? Definitely when doing simple applications, Proof of Concept apps (POC) or pet projects. The rule of the thumb here is: if you can manage with just one NavigationStack displayed at one time, you’ll be fine.

Also, another interesting use case might be cross-platform applications with native UI clients, e.g. Kotlin Multiplatform Mobile (KMM). Such applications often implement managing the navigation state in the multiplatform module. The client apps are responsible for rendering that state, which the proposed approach seems perfect for. 

Are there projects where I would not recommend using this type of navigation? Unfortunately, yes. Let’s start with UIKit-dominated projects. They most certainly have already established a way to manage navigation in the app. Unless the feature we want to use this approach in is fairly isolated from the rest of the app, there could be glitches and conflicts between the navigation components. Also, if an absolute control is needed over the navigation stack: custom transitions between views, ability to interrupt started navigation action, unconventional UI, etc., it might be prudent to stay within the comfort zone offered by the UINavigationController.

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Some images were generated using Leonardo AI ❤️