From broken to testable SwiftUI navigation: The decoupled approach of MVVM with coordinators

10 min read Original article ↗

SwiftUI provides several tools for managing navigation, and the introduction of NavigationStack and value-destination links improved programmatic navigation.

However, in larger applications, vanilla SwiftUI navigation can pose challenges for testability, maintainability, and modularity. Navigation logic is distributed across views, introducing coupling and making the navigation code hard to locate.

These problems can be addressed by integrating coordinators into the MVVM pattern.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Table of contents

  1. Vanilla SwiftUI navigation is intuitive in small apps, but causes architectural issues at scale
  2. Coordinators help remove untestable logic from SwiftUI views
  3. Coordinators simplify the injection of complex dependencies into view models
  4. Coordinators centralize scattered navigation logic and remove coupling between views
  5. Coordinators centralize the app’s navigation management for deep links
  6. Coordinators and route values enable unit testing of a SwiftUI app’s navigation

Vanilla SwiftUI navigation is intuitive in small apps, but causes architectural issues at scale

SwiftUI’s NavigationStack allows you to build sophisticated navigation hierarchies. For example, this is a typical arrangement for an app featuring a tab view, with individual drill-down navigation in each tab.

struct ContentView: View {
	var body: some View {
		TabView {
			Tab("Recipes", systemImage: "list.bullet.clipboard") {
				NavigationStack {
					RecipesList()
				}
			}
			Tab("Settings", systemImage: "gear") {
				NavigationStack {
					SettingsView()
				}
			}
		}
	}
}

The NavigationLink view allows us to specify:

  • Value-destination links that push values onto a navigation path.
  • View-destination links that push views directly onto the navigation stack.

While these are both useful, depending on the use case, they both cause architectural issues at scale.

You can follow the code in this article by downloading the complete Xcode project from GitHub.

Note

I will deliberately keep the examples simple to make them straightforward to understand.

It is crucial to understand that none of the following examples, by itself, constitutes a problem. All examples will show perfectly acceptable code for simple apps.

Problems arise only when a codebase grows and its architectural requirements change.

Coordinators help remove untestable logic from SwiftUI views

Value-destination links operate on types. For example, we can display a RecipeView when a Recipe value is pushed onto the navigation path.

struct ContentView: View {
	var body: some View {
		TabView {
			Tab("Recipes", systemImage: "list.bullet.clipboard") {
				NavigationStack {
					RecipesList()
						.navigationDestination(for: Recipe.self) { recipe in
							RecipeView(recipe: recipe)
						}
				}
			}
			// ...
		}
	}
}

This approach remains purely declarative until we need to inspect data to make a navigation decision. For example, our recipes app might offer premium recipes gated behind a paywall.

struct ContentView: View {
	var body: some View {
		TabView {
			Tab("Recipes", systemImage: "list.bullet.clipboard") {
				NavigationStack {
					RecipesList()
						.navigationDestination(for: Recipe.self) { recipe in
							if recipe.isPremium {
								PaywallView()
							} else {
								RecipeView(recipe: recipe)
							}
						}
				}
			}
			// ...
		}
	}
}

Incorporating these checks introduces non-UI logic into views, creating several architectural problems:

We can solve the first two problems straight away by moving the navigation logic into a coordinator class.

@Observable final class Coordinator {
	@ViewBuilder func destination(for recipe: Recipe) -> some View {
		if recipe.isPremium {
			PaywallView()
		} else {
			RecipeView(recipe: recipe)
		}
	}
}
struct ContentView: View {
	@State var coordinator = Coordinator()

	var body: some View {
		TabView {
			Tab("Recipes", systemImage: "list.bullet.clipboard") {
				NavigationStack {
					RecipesList()
						.navigationDestination(for: Recipe.self) { recipe in
							coordinator.destination(for: recipe)
						}
				}
			}
			// ...
		}
	}
}

However, testing this code is not straightforward, since the @ViewBuilder attribute causes the destination(for:) to return a _ConditionalContent<PaywallView, RecipeView> value that we cannot inspect. 

We will see how to fix this by the end of the article.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Coordinators simplify the injection of complex dependencies into view models

Another problem arises with views that use a view model that requires dependencies to be injected via its initializer.

Because environment objects are not yet available in a view’s initializer, the view model must be initiated in the task(priority:_:) view modifier and stored in an optional property.

@Observable final class NetworkController {
	// ...
}

@Observable final class ViewModel {
	let networkController: NetworkController

	init(networkController: NetworkController) {
		self.networkController = networkController
	}
}

struct PaywallView: View {
	@State private var viewModel: ViewModel?
	@Environment(NetworkController.self) private var networkController

	var body: some View {
		Text("Hello, World!")
			.navigationTitle("Paywall")
			.task {
				guard viewModel == nil else { return }
				viewModel = ViewModel(networkController: networkController)
			}
	}
}

Many developers take issue with this approach, complaining that optionals lead to several annoying unwrapping steps in the view’s code. 

However, instantiating the view model in the parent of PaywallView would introduce additional coupling across views and violate the Single Responsibility and the Don’t Repeat Yourself (DRY) principles, especially when the PaywallView is accessible via multiple navigation paths.

We can avoid these problems and remove the optional from the view by injecting the dependencies into the view model from a coordinator.

struct PaywallView: View {
	@State private var viewModel: ViewModel 

	init(viewModel: ViewModel) {
		self._viewModel = State(initialValue: viewModel)
	}

	var body: some View {
		Text("Hello, World!")
			.navigationTitle("Paywall")
		   
	}
}

@Observable final class Coordinator {
	let networkController = NetworkController()

	@ViewBuilder func destination(for recipe: Recipe) -> some View {
		if recipe.isPremium {
			let viewModel = ViewModel(networkController: networkController)
			PaywallView(viewModel: viewModel)
		} else {
			RecipeView(recipe: recipe)
		}
	}
}
Note

The NetworkController might also need to be injected into the Coordinator through an initializer.

Coordinators centralize scattered navigation logic and remove coupling between views

At times, there might not be a relationship between data and navigation. In such cases, SwiftUI offers view-destination links rather than value-destination links.

For example, a Settings view might explicitly declare the destination view for each row in a Form.

struct SettingsView: View {
	var body: some View {
		Form {
			NavigationLink(destination: { ProfileView() }) {
				Label("Profile", systemImage: "person.crop.circle")
			}
			NavigationLink(destination: { AllergiesView() }) {
				Label("Allergies", systemImage: "leaf")
			}
		}
		.navigationTitle("Settings")
	}
}

You would be able to gather the mapping of value-destination links inside several navigationDestination(for:_:) view modifiers at the root of a navigation tree inside a NavigationStack.

However, view-destination links distribute navigation responsibilities across multiple views, as they must reside in the view triggering the navigation.

This introduces coupling between views, and, in large apps, it makes it challenging to locate the exact navigation points in the codebase. Identifying specific routes can be time-consuming, and code updates can affect unrelated parts of the system.

Coordinators let us gather all navigation destinations in a single location, making it easier to understand the app’s entire navigation at a glance without drilling into the codebase.

@Observable final class Coordinator {
	// ...

	@ViewBuilder func profileSettings() -> some View {
		ProfileView()
	}

	@ViewBuilder func allergiesSettings() -> some View {
		ProfileView()
	}
}

The coordinator also removes coupling between views.

struct ContentView: View {
	@State var coordinator = Coordinator()

	var body: some View {
		TabView(selection: $coordinator.tab) {
			// ...
		}
		.environment(coordinator)
	}
}

struct SettingsView: View {
	@Environment(Coordinator.self) private var coordinator

	var body: some View {
		Form {
			NavigationLink(destination: { coordinator.profileSettings() }) {
				Label("Profile", systemImage: "person.crop.circle")
			}
			NavigationLink(destination: { coordinator.allergiesSettings() }) {
				Label("Allergies", systemImage: "leaf")
			}
		}
		.navigationTitle("Settings")
	}
}

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Another problem caused by value-destination and view-destination links at scale is that they decentralize navigation management, making it hard or impossible to bring an app to a specific state via a deep link.

A coordinator can instead control the entire navigation state of an app, including tab views and modal presentation.

First, it should be noted that view-destination links are not suitable for deep linking. According to Apple’s documentation:

A view-destination link is fire-and-forget: SwiftUI tracks the navigation state, but from your app’s perspective, there are no stateful hooks indicating you pushed a view.

Hence, we need values even for those paths that we would typically handle with view-destination links.

enum AppSection {
	case recipes, settings
}

enum SettingsRoute {
	case main, profile, allergies
}

@Observable final class Coordinator {
	var appSection: AppSection = .recipes
	var settingsPath: [SettingsRoute] = []
	//...

	@ViewBuilder func view(for route: SettingsRoute) -> some View {
		switch route {
			case .main: SettingsView()
			case .profile: ProfileView()
			case .allergies: AllergiesView()
		}
	}

	func handleURL(_ url: URL) {
		appSection = .settings
		settingsPath = [.main, .allergies]
	}
}

The view(for:) method maps each SettingsRoute to a view. The handleURL(_:) method can then respond to a deep link, switching the app to the Settings tab and pushing the AllergiesView onto its navigation stack.

The connection is made inside the ContentView, which is the root view where the entire app’s navigation structure is defined.

struct ContentView: View {
	@State var coordinator = Coordinator()

	var body: some View {
		TabView(selection: $coordinator.appSection) {
			Tab("Recipes", systemImage: "list.bullet.clipboard", value: .recipes) {
				// ...
			}
			Tab("Settings", systemImage: "gear", value: .settings) {
				NavigationStack(path: $coordinator.settingsPath) {
					coordinator.view(for: .main)
						.navigationDestination(for: SettingsRoute.self) { route in
							coordinator.view(for: route)
						}
				}
			}
		}
		.environment(coordinator)
		.onOpenURL { url in
			coordinator.handleURL(url)
		}
	}
}

Deep links often come from outside an app, but they can also be used internally to jump to a specific point in response to user actions or events.

struct RecipesList: View {
	@State var recipes = Recipe.data

	var body: some View {
		List {
			ForEach(recipes) { recipe in
				// ...
			}
			Link(
				"Set your allergies",
				destination: URL(string: "recipes://settings/allergies")!
			)
		}
		.listStyle(.plain)
		.navigationTitle("Recipes")
	}
}

Do not forget to set your app’s URL scheme in the target’s Info to enable it to respond to incoming deep links.

Coordinators and route values enable unit testing of a SwiftUI app’s navigation

Thanks to our coordinator, we can now write a test to verify that a deep link leads to the correct location.

@Test func allergiesDeepLink() async throws {
	let coordinator = Coordinator()
	coordinator.handleURL(URL(string: "recipes://settings/allergies")!)
	#expect(coordinator.appSection == .settings)
	#expect(coordinator.settingsPath == [.main, .allergies])
}

There is still an indirect link between the SettingsRoute values and the relative views that our test cannot cover. However, that is the domain of UI tests, as the goal of a unit test is to verify an app’s logic.

This means that testing the destination for premium recipes also requires explicitly handling the navigation path of the Recipes navigation stack in the coordinator.

Conclusions

Standard SwiftUI navigation is enough for basic applications, but it introduces several architectural problems at scale.

Coordinators within the MVVM pattern centralize routing, removing view coupling, enabling deep linking, and improving separation of concerns and testability.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE