Using a NavigationStack with MVVM and SwiftUI

Will Lumley | Apr 5, 2024 min read

In the ever-evolving landscape of iOS development, SwiftUI has marked a revolution with its declarative syntax and promise of simplifying complex UI implementations. As developers, we constantly seek architectures that not only streamline development but also enhance maintainability and testability. Among these, the Model-View-ViewModel (MVVM) pattern has emerged as a favorite for many, including myself, for its clear separation of concerns and ease of integration with SwiftUI. However, even the most seasoned developers can encounter hurdles that challenge their preferred methodologies.

I was working on my first production-level SwiftUI application, which uses MVVM, when I first encountered what I initially assumed to be a quickly solved problem. Despite SwiftUI’s graceful declarative syntax and promises of straightforward UI development, I couldn’t find an obvious solution to this problem:

How in the hell do I incorporate a NavigationStack into SwiftUI and keep my ViewModel and View layers seperate and clean?

After doing some research and not being entirely satisfied with the potential solutions that people had posted, I decided to put my big boy boots on and try it myself.

Now I’ll give you fair warning - I’m not claiming this is the best solution nor the perfect solution - however I found it to be exactly what I needed. Nothing crazy complicated nor over the top, and allows for navigating between views in a NavigationStack - all while keeping View and ViewModel seperate.

I recommend downloading the finished project, linked below, and read through the code with me. https://github.com/will-lumley/swiftui-navigation-example


Let’s meet the crew

  • RootViewModel
    • You can think of the RootViewModel as the ViewModel for the NavigationStack itself. Or, to think in UIKit terms for a moment, as the ViewModel for the UINavigationController.
  • RootView
    • This is the View that will contain the NavigationStack, and for all intents and purposes, be the NavigationStack. To think in UIKit terms for another moment, this would be the UINavigationController.
  • NavigationCoordinator
    • This is the protocol that RootViewModel must conform to, and is what essentially turns our RootViewModel from any ViewModel to one that can handle navigation events, and in turn one that can communicate with the NavigationStack on the View layer.
  • FirstContentView & FirstContentViewModel
    • This is our first example View & ViewModel in our app, that represent the first View & ViewModel you would have in your application.
  • SecondContentView & SecondContentViewModel
    • This is our second example View & ViewModel in our app, that are pushed onto the NavigationStack from the FirstContentView / FirstContentViewModel.
  • ThirdContentView & ThirdContentViewModel
    • This is our third example View & ViewModel in our app, that are pushed onto the NavigationStack from the SecondContentView / SecondContentViewModel.

Our NavigationCoordinator is crucial in separating the View & ViewModel layers. NavigationCoordinator allows us to abstract navigation actions such as pushing and popping views, keeping our ViewModel away from the intricacies of SwiftUI’s navigation system. It’s extremely simple, as you can see below.

public protocol NavigationCoordinator {
    func push(_ path: any Hashable)
    func popLast()
}

RootViewModel

RootViewModel is where the magic happens. I’ve pasted it’s contents down below. Have a browse through it, and I’ll catch up with you down below and we can go over it together.

import Foundation
import SwiftUI

public class RootViewModel: ObservableObject, Identifiable {

    // MARK: - Types

    public enum Path: Hashable {
        case first(FirstContentViewModel)
        case second(SecondContentViewModel)
        case third(ThirdContentViewModel)
    }

    // MARK: - Properties

    public var id = UUID()

    /// The object that handles our navigation stack
    @Published public var paths = NavigationPath()

    /// The ViewModel that represents our first view in the navigation stack
    public lazy var firstContentViewModel: FirstContentViewModel = {
        .init(navigator: self, text: "First!")
    }()

}

// MARK: - NavigationCoordinator

extension RootViewModel: NavigationCoordinator {

    public func push(_ path: any Hashable) {
        DispatchQueue.main.async { [weak self] in
            self?.paths.append(path)
        }
    }

    public func popLast() {
        DispatchQueue.main.async { [weak self] in
            self?.paths.removeLast()
        }
    }

}

Great. Now we’ll go through RootViewModel piece by piece.

Let’s start off with the Path type.

    public enum Path: Hashable {
        case first(FirstContentViewModel)
        case second(SecondContentViewModel)
        case third(ThirdContentViewModel)
    }

This enum represents every possible ViewModel that can be pushed onto the NavigationStack. In this example, we have three ViewModel’s, and each one is listed here and contained within an associated value.

Note that Path conforms to Hashable, which means that each of your ViewModel’s must conform to Hashable. Don’t worry though, this is simple to implement.

Now we have our paths property:

@Published public var paths = NavigationPath()

The paths property is the object that we will store our Path.first() or Path.second() or Path.third(), etc, when pushed.

We use the @Published property wrapper so that whenever a value is added or taken away from paths, SwiftUI can receive the updates automatically.

Keen eyed readers would have spotted something wrong with this though.

Can you spot it?

That’s right! NavigationPath belongs to SwiftUI, and importing/using SwiftUI within our ViewModel layer is a no-no. Alas, I could not figure out a way to get this to work, cleanly, without using this.

So while not great - all things considered - it’s not the end of the world.

Onto the next property, this lazy-loaded variable:

    /// The ViewModel that represents our first view in the navigation stack
    public lazy var firstContentViewModel: FirstContentViewModel = {
        .init(navigator: self, text: "First!")
    }()

This property, firstContentViewModel, is our first ViewModel in our NavigationStack. We store it as a property of our RootViewModel so that we can load the ViewModel as soon as RootViewModel loads up.

You’ll notice how FirstContentViewModel has a navigator parameter in it’s instance. Each ViewModel in our NavigationStack will have this property and have it passed to it through it’s initialiser.

It is needed as it is how it pushes and pops views in the navigation flow. As for how it does this, we’ll get into that later on.

Let’s now hvae a look at RootViewModel’s conformance to NavigationCoordinator:

extension RootViewModel: NavigationCoordinator {

    public func push(_ path: any Hashable) {
        DispatchQueue.main.async { [weak self] in
            self?.paths.append(path)
        }
    }

    public func popLast() {
        DispatchQueue.main.async { [weak self] in
            self?.paths.removeLast()
        }
    }

}

These functions, push(_ path:) and popLast(), will be called by ViewModel’s in the navigation flow.

In regards to push(_ path:), the path parameter will be a RootViewModel.Path type, with it’s type erased to any Hashable. We ensure that any changes to the paths variable is done on the main thread with DispatchQueue.main.async, as changes to paths propagate to the View layer, which of course must be performed on the main thread.

popLast() simply removes the last item from the paths “array”.

Just to reiterate - any changes made to the paths array will update the navigation flow. So while we now have the foundation for us to push ViewModels to our paths, how do we connect a ViewModel being pushed onto the navigation flow with a View being pushed onto the NavigationStack?

Enter RootView:

RootView

RootView listens to any changes made to the RootViewModel and will either connect the ViewModel pushed to it’s View being pushed, and remove (or “pop”) any View from the NavigationStack once it’s ViewModel is removed.

Let’s take a look at RootView now:

struct RootView: View {

    // MARK: - Properties

    @StateObject var viewModel: RootViewModel

    // MARK: - View

    var body: some View {
        
        NavigationStack(path: $viewModel.paths) {
            FirstContentView(viewModel: viewModel.firstContentViewModel)
                .navigationDestination(for: RootViewModel.Path.self) { path in
                    switch path {
                    case .first(let viewModel):
                        FirstContentView(viewModel: viewModel)
                    case .second(let viewModel):
                        SecondContentView(viewModel: viewModel)
                    case .third(let viewModel):
                        ThirdContentView(viewModel: viewModel)
                    }
                }
        }

    }
}

The NavigationStack encapsulates all views within RootView with the SwiftUI equivalent of UINavigationController. We pass in our ViewModels variable of paths (and use $ to make it Binding). This enables two things:

  1. The NavigationStack instance can now listen to any updates from the paths property, and when a change is made push (or pop) a View accordingly. A push operation will map the ViewModel to the View in the .navigationDestination modifier.
  2. The NavigationStack can itself modify the paths property. This would happen when a user selects the built-in Navigation back button, and then the latest ViewModel would need to be popped.

As the first view in our NavigationStack is FirstContentView, we instantiate it within our NavigationStack, with the lazy-loaded variable within RootViewModel we wrote earlier.

Our .navigationDestination modifier listens to any updates in paths that are of type RootViewModel.Path. We can then run a switch statement and determine the path type.

Here we can then get the Path type, which will give us our ViewModel. This is where the connection and link between ViewModel and View happens. We will then declare a View, depending on the ViewModel. The View that is declared is the View that will be pushed onto the NavigationStack.

FirstContentView

Our FirstContentView is pretty straightforward, so I won’t spend time going over it. It’s also very similar to SecondContentView and ThirdContentView, so I won’t bother going over those either.

struct FirstContentView: View {

    // MARK: - Properties

    @StateObject var viewModel: FirstContentViewModel

    // MARK: - View

    var body: some View {
        VStack {
            Text(viewModel.text)
                .font(.headline)
                .padding()

            Spacer()

            Button("Next Screen") {
                viewModel.nextButtonSelected()
            }
            .buttonStyle(BorderedButtonStyle())
            .padding()

            Button("Back") {
                viewModel.backButtonSelected()
            }
            .buttonStyle(BorderedButtonStyle())
            .padding()
        }
        .navigationTitle("First View")
    }
}

FirstContentViewModel

Let’s now take a good look at FirstContentViewModel. This will demonstrate how to push and pop ViewModels (and by extension, Views) onto our navigation flow.

public final class FirstContentViewModel: ObservableObject {

    // MARK: - Properties

    @Published public var text: String

    private let navigator: NavigationCoordinator

    // MARK: - Lifecycle

    init(navigator: NavigationCoordinator, text: String) {
        self.navigator = navigator
        self.text = text
    }

}

// MARK: - Public

public extension FirstContentViewModel {

    func nextButtonSelected() {
        self.navigator.push(
            RootViewModel.Path.second(
                .init(navigator: navigator, text: "Second!")
            )
        )
    }

}

// MARK: - Hashable

extension FirstContentViewModel: Hashable {

    public static func == (lhs: FirstContentViewModel, rhs: FirstContentViewModel) -> Bool {
        return lhs.text == rhs.text
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(self.text)
    }
    
    
}

The text property is just a simple example of a property that might exist on the ViewModel that you pass onto the View. You can ignore it.

The navigator property is much more important.

private let navigator: NavigationCoordinator

This is the property our ViewModel will use to manipulate the navigation flow.

func nextButtonSelected() {
        self.navigator.push(
            RootViewModel.Path.second(
                SecondContentViewModel(navigator: navigator, text: "Second!")
            )
        )
    }

As you can see in our nextButtonSelected() function, our ViewModel can push a ViewModel onto the navigation flow.

When you test this code, you’ll be able to see that pushing SecondContentViewModel onto our navigator will push the SecondContentView onto our NavigationStack, and will configure that SecondContentView with our SecondContentViewModel that we created & configured.

Popping a View/ViewModel doesn’t exist in FirstContentView/FirstContentViewModel as it’s our inital View/ViewModel, so let’s take a look at how deeper ViewModel’s perform it.

    func backButtonSelected() {
        self.navigator.popLast()
    }

How easy is that?

Unit Tests, oh my!

The standout feature of MVVM is its ability to facilitate the testing of both business logic and view logic independently, eliminating the need to configure views within a unit test - which is notoriously difficult.

So now that we have all this setup, let’s have a look at how we can test our navigation.

As NavigationCoordinator is a protocol, we can create a separate NavigationCoordinator that will listen out for pushes and pops, but rather than binding them to a View’s NavigationStack, we will broadcast these events using closures.

MockNavigationCoordinator

class MockNavigationCoordinator: NavigationCoordinator {

    // MARK: - Types

    typealias OnPush = (_ path: any Hashable) -> Void
    typealias OnPop = () -> Void

    let onPush: OnPush?
    let onPop: OnPop?

    // MARK: - Lifecycle

    init() {
        self.onPush = nil
        self.onPop = nil
    }

    init(onPush: @escaping OnPush, onPop: @escaping OnPop) {
        self.onPush = onPush
        self.onPop = onPop
    }

    // MARK: - NavigationCoordinator

    func push(_ path: any Hashable) {
        onPush?(path)
    }

    func popLast() {
        onPop?()
    }

}

Now we can write our unit test:

final class FirstContentViewModelTests: XCTestCase {

    func testNavigation() throws {
        var pushed: (any Hashable)?
        var popped = false

        // Create our MockNavigationCoordinator so we can listen to push/pop events
        let navigator = MockNavigationCoordinator(
            onPush: {
                // Record what path was just pushed
                pushed = $0
            },
            onPop: {
                // Record that we just popped
                popped = true
            }
        )

        // Setup our TestSubject
        let testSubject = FirstContentViewModel(navigator: navigator, text: "test text")

        // We'll do some basic house cleaning
        XCTAssertEqual(testSubject.text, "test text")

        // Tell our FirstContentViewModel to select our next button
        testSubject.nextButtonSelected()

        // A path should just now be pushed onto our NavigationCoordinator
        let pushedPath = try XCTUnwrap(pushed as? RootViewModel.Path)

        // And that path should contain a SecondContentViewModel
        guard case .second(let secondViewModel) = pushedPath else {
            XCTFail("Incorrect type returned")
            return
        }

        // The SecondContentViewModel should contain the correct properties
        XCTAssertEqual(secondViewModel.text, "Second!")

        // If we go back with our SecondContentViewModel...
        secondViewModel.backButtonSelected()

        // ...we should have that recorded
        XCTAssertTrue(popped)
    }

}

This approach allows for precise tracking and verification of navigation behaviour. For instance, when the FirstContentViewModel triggers a navigation to the SecondContentViewModel, we not only verify that a navigation action occurred but also validate the type of view model that was navigated to, ensuring it aligns with expected behaviour. This is achieved by capturing the navigation action’s target and evaluating its properties, which in this case, includes verifying the text property of the SecondContentViewModel.

Moreover, the test suite goes a step further by simulating user interaction that leads to a navigation action (e.g., tapping a ’next’ button), and subsequently, confirming that the correct navigation path is taken. This includes asserting the transition from the FirstContentViewModel to the SecondContentViewModel and then testing the reverse navigation (pop action) back to the original state.

Conclusion

Navigating SwiftUI with MVVM can feel daunting, but the solution we’ve explored—leveraging NavigationStack, RootViewModel, and the NavigationCoordinator protocol—demonstrates that it’s entirely possible to maintain clean architecture while embracing SwiftUI’s modern UI paradigms. This approach not only streamlines navigation but also ensures our code remains flexible and maintainable.

However, the tech landscape is always evolving, and today’s solutions may pave the way for more refined approaches tomorrow. This journey isn’t just about overcoming a challenge; it’s about opening a dialogue on continuous improvement and innovation within our community.

I invite you to experiment with this framework, adapt it to your needs, and share your insights. Have you faced similar hurdles? What strategies worked for you?

Thank you for reading, and here’s to the next challenge we tackle together.

Photograph Credit: Jamie Street, Source: https://unsplash.com/photos/person-holding-compass-facing-towards-green-pine-trees-_94HLr_QXo8?utm_content=creditShareLink&utm_medium=referral&utm_source=unsplash