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 theUINavigationController
.
- You can think of the
RootView
- This is the View that will contain the
NavigationStack
, and for all intents and purposes, be theNavigationStack
. To think in UIKit terms for another moment, this would be theUINavigationController
.
- This is the View that will contain the
NavigationCoordinator
- This is the protocol that
RootViewModel
must conform to, and is what essentially turns ourRootViewModel
from any ViewModel to one that can handle navigation events, and in turn one that can communicate with theNavigationStack
on the View layer.
- This is the protocol that
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.
- This is our second example View & ViewModel in our app, that are pushed onto the
ThirdContentView
&ThirdContentViewModel
- This is our third example View & ViewModel in our app, that are pushed onto the
NavigationStack
from the SecondContentView / SecondContentViewModel.
- This is our third example View & ViewModel in our app, that are pushed onto the
NavigationCoordinator
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:
- The
NavigationStack
instance can now listen to any updates from thepaths
property, and when a change is made push (or pop) a View accordingly. Apush
operation will map the ViewModel to the View in the.navigationDestination
modifier. - The
NavigationStack
can itself modify thepaths
property. This would happen when a user selects the built-in Navigation back button, and then the latestViewModel
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