Skip to the article
henaifuos.com
Swift

MVVM-C with SwiftUI and Combine: navigation that stays testable

SwiftUI makes it easy to push navigation into the view layer, and that is exactly what makes screens hard to test and reuse. MVVM-C adds one more role, the coordinator, so views render, view models decide and one object owns where the app goes next.

By Soufiane H. 6 min read

The view sends intents, the view model emits events and the coordinator updates the navigation path.

What the C adds

MVVM splits a screen into three parts. The view draws state and forwards taps. The view model holds that state and the logic behind it. The model or service layer fetches and stores data. It works well until a tap has to open another screen.

The usual shortcut is a NavigationLink inside the view, or a view model that knows about the next screen. Both tie screens together: a list now knows how to build its detail view, and a unit test for the list has to cope with navigation it does not care about.

MVVM-C, which is MVVM with Coordinators, removes that coupling with one rule: nobody except the coordinator knows what comes next.

  • View: renders state and sends intents such as taps. It never creates another screen.
  • View model: owns state and logic. It reports what happened as events, without saying where to go.
  • Coordinator: listens to events, owns the navigation path and builds each screen with its dependencies.

Routes and the coordinator

Start by naming every destination as a value. A Hashable enum works with NavigationStack, survives state restoration and doubles as a deep-link format.

SwiftAppCoordinator.swift
enum Route: Hashable {
    case detail(id: Int)
    case settings
}

final class AppCoordinator: ObservableObject {
    @Published var path: [Route] = []

    func show(_ route: Route) { path.append(route) }
    func pop() { _ = path.popLast() }
    func popToRoot() { path.removeAll() }
}

The coordinator is an ObservableObject because NavigationStack reads and writes its path through a binding. Anything that changes path, whether a tap, a deep link or a test, changes what is on screen.

SwiftRootView.swift
struct RootView: View {
    @ObservedObject var coordinator: AppCoordinator

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            ListView(viewModel: coordinator.listViewModel)
                .navigationDestination(for: Route.self) { route in
                    coordinator.destination(for: route)
                }
        }
    }
}

View models emit events

The list view model needs no idea that a detail screen exists. It publishes what happened and nothing else.

SwiftListViewModel.swift
import Combine

struct Item: Identifiable, Equatable {
    let id: Int
    let title: String
}

enum ListEvent: Equatable {
    case didSelect(id: Int)
    case didTapSettings
}

final class ListViewModel: ObservableObject {
    @Published private(set) var items: [Item]
    let events = PassthroughSubject<ListEvent, Never>()

    init(items: [Item]) { self.items = items }

    func select(_ item: Item) { events.send(.didSelect(id: item.id)) }
    func settingsTapped() { events.send(.didTapSettings) }
}

@Published carries state to the view. A PassthroughSubject carries one-off events to the coordinator. Events are not state: a navigation request should fire once and be forgotten, not sit in a property that the next screen reads again.

Wiring the two with Combine

The coordinator creates the view model, subscribes to its events and translates each one into a route.

SwiftAppCoordinator.swift
import Combine

final class AppCoordinator: ObservableObject {
    @Published var path: [Route] = []

    // Load the items from your repository in a real app.
    let listViewModel = ListViewModel(items: [])
    private var cancellables = Set<AnyCancellable>()

    init() {
        listViewModel.events
            .sink { [weak self] event in self?.handle(event) }
            .store(in: &cancellables)
    }

    private func handle(_ event: ListEvent) {
        switch event {
        case .didSelect(let id): show(.detail(id: id))
        case .didTapSettings: show(.settings)
        }
    }

    func show(_ route: Route) { path.append(route) }
}

Detail screens are built when the route is shown. The coordinator hands each one its dependencies, and the view creates its view model through StateObject.

SwiftDetailView.swift
extension AppCoordinator {
    @ViewBuilder
    func destination(for route: Route) -> some View {
        switch route {
        case .detail(let id):
            DetailView(viewModel: DetailViewModel(id: id))
        case .settings:
            SettingsView()
        }
    }
}

final class DetailViewModel: ObservableObject {
    let id: Int
    init(id: Int) { self.id = id }
}

struct DetailView: View {
    @StateObject private var viewModel: DetailViewModel

    init(viewModel: @autoclosure @escaping () -> DetailViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel())
    }

    var body: some View {
        Text("Item \(viewModel.id)")
    }
}

SwiftUI can call destination(for:) many times. Passing the view model through an autoclosure into StateObject means it is built once for the life of the screen, not on every refresh.

Testing without a UI

Both halves test with plain XCTest and no view hierarchy. The view model test checks the event. The coordinator test checks the route.

SwiftNavigationTests.swift
import XCTest
import Combine

final class NavigationTests: XCTestCase {
    func test_selectingAnItemEmitsDidSelect() {
        let item = Item(id: 7, title: "Seven")
        let sut = ListViewModel(items: [item])
        var received: [ListEvent] = []
        let cancellable = sut.events.sink { received.append($0) }

        sut.select(item)

        XCTAssertEqual(received, [.didSelect(id: 7)])
        cancellable.cancel()
    }

    func test_didSelectPushesTheDetailRoute() {
        let sut = AppCoordinator()

        sut.listViewModel.select(Item(id: 7, title: "Seven"))

        XCTAssertEqual(sut.path, [.detail(id: 7)])
    }
}

Deep links get the same treatment: parse a URL into [Route], assign it to path and assert on the result.

Mistakes to avoid

  1. Building view models in body. A view model created inside body is recreated on every refresh and loses its state. Create it once, in the coordinator or inside the StateObject autoclosure.
  2. Strong references in sink closures. The coordinator owns the subscription, and the closure points back at the coordinator. Capture [weak self].
  3. One giant route enum. When a flow such as sign-up or checkout has its own steps, give it a child coordinator with its own path and let it report back when it finishes.
  4. Treating events as state. If a screen only works when a @Published flag flips, you have rebuilt the problem MVVM-C removes. Use a subject for one-off events.

In short

Give each role one job: views render, view models decide, the coordinator navigates. You get screens that preview and test in isolation, navigation you can drive from a deep link, and flows you can rearrange without touching a single view.

Next: carrying this architecture over to Dart in From iOS to Flutter: what carries over and what does not.

About the author

Soufiane H. is an iOS and Flutter developer in Casablanca who designs, builds and publishes mobile apps for iOS and Android.

Keep reading