SwiftUI navigation with Coordinator Views and Action emitting ViewModels

Two rules introducing Coordinator Views and Action emitting ViewModels

Thomas Asheim Smedmann
6 min readJun 25, 2024
Coordinator Views and Action emitting ViewModels.

Edit: Updated all example code to use StateObject and ObservableObject, as everything will break when using the new Observation Framework (with the Observable macro). This is due to how State and StateObject are initialized, where State gets passed an initial value directly but StateObject gets passed an autoclosure. This makes sure the StateObject’s wrapped object only gets initialized once per lifetime of the containing View. And therefore makes sure the ViewModel factory methods only gets called once (as calls to factory methods gets wrapped in an autoclosure in the example code).

Edit 2: For a more robust way of managing ViewModels (which also supports the Observation Framework), check out Driving SwiftUI navigation with a ViewModel hierarchy.

With the current state of things there is not really any good or bad ways to manage navigation in SwiftUI apps. So until the Swift/SwiftUI community settles on a couple of battle tested patterns — here is another one!

Coordinator Views and Action emitting ViewModels

Heavily inspired by two well known concepts in the world of UIKit apps, Coordinators and ViewModels, the pattern/idea can be condensed into two rules. Two rules describing three concepts.

Two rules, three concepts

  • ViewModels emit Actions as a result of user interaction or some other asynchronous event.
  • CoordinatorViews deals with navigation and their ViewModels act as factories for other ViewModels.

Check out Driving SwiftUI navigation with a ViewModel hierarchy! That article further improves on the stuff from this article, and looks at the idea of a changing — but connected — ViewModel hierarchy to drive SwiftUI Navigation.

With this in mind, let's explore a possible app setup.

A MainCoordinatorView…

…With its child CoordinatorViews (with their navigation flows) presented as tabs, full-screen pages or as sheets.

import SwiftUI

struct MainCoordinatorView: View {
@StateObject var viewModel: ViewModel

var body: some View {
TabView(selection: $viewModel.selectedTab) {
HomeCoordinatorView(
viewModel: viewModel.makeHomeCoordinatorViewModel()
)
.tabItem {
Label(Tab.home.title, systemImage: Tab.home.systemImage)
}

MoreCoordinatorView(
viewModel: viewModel.makeMoreCoordinatorViewModel()
)
.tabItem {
Label(Tab.more.title, systemImage: Tab.more.systemImage)
}
}
.sheet(item: $viewModel.presentedSheet) { sheet in
switch sheet {
case .settings:
SettingsCoordinatorView()
}
}
.fullScreenCover(item: $viewModel.presentedPage) { page in
switch page {
case .enrollment:
EnrollmentCoordinatorView(
viewModel: viewModel.makeEnrollmentCoordinatorViewModel()
)
}
}
}
}

// MARK: MainCoordinatorView+Tab

extension MainCoordinatorView {
/// An enumeration representing possible navigation flows that can be hosted as Tabs by the ``MainCoordinatorView``.
enum Tab: Identifiable {
case home
case more

var id: Self { self }

var title: LocalizedStringKey {
switch self {
case .home: "Home"
case .more: "More"
}
}

var systemImage: String {
switch self {
case .home: "house"
case .more: "square.grid.2x2"
}
}
}
}

// MARK: MainCoordinatorView+Sheet

extension MainCoordinatorView {
/// An enumeration representing possible navigation flows that can be presented as Sheets by the ``MainCoordinatorView``.
enum Sheet: Identifiable {
case settings

var id: Self { self }
}
}

// MARK: MainCoordinatorView+Page

extension MainCoordinatorView {
/// An enumeration representing possible navigation flows that can be presented full-screen as Pages by the ``MainCoordinatorView``.
enum Page: Identifiable {
case enrollment

var id: Self { self }
}
}

A MainCoordinatorView’s ViewModel…

…With its navigation state variables (selectedTab, presentedPage and presentedSheet) and ViewModel factory methods for making the ViewModels of child CoordinatorViews.

import SwiftUI

extension MainCoordinatorView {
@MainActor
final class ViewModel: ObservableObject {
@Published var selectedTab: Tab = .home
@Published var presentedPage: Page? = nil
@Published var presentedSheet: Sheet? = nil

// ...
}
}

extension MainCoordinatorView.ViewModel {
func makeHomeCoordinatorViewModel() -> HomeCoordinatorView.ViewModel {
HomeCoordinatorView.ViewModel { [weak self] action in
switch action {
case .didTapSettings:
self?.presentedSheet = .settings
}
}
}

func makeMoreCoordinatorViewModel() -> MoreCoordinatorView.ViewModel {
MoreCoordinatorView.ViewModel { [weak self] action in
switch action {
case .didTapReEnroll:
self?.presentedPage = .enrollment
}
}
}

func makeEnrollmentCoordinatorViewModel() -> EnrollmentCoordinatorView.ViewModel {
EnrollmentCoordinatorView.ViewModel { [weak self] action in
switch action {
case .didCompleteEnrollment:
self?.presentedPage = nil
}
}
}
}

A HomeCoordinatorView…

…With its navigation flow contained in a NavigationStack.

import SwiftUI

struct HomeCoordinatorView: View {
@StateObject var viewModel: ViewModel

var body: some View {
NavigationStack(path: $viewModel.pageStack) {
HomeView(
viewModel: viewModel.makeHomeViewModel()
)
.navigationDestination(for: SubPage.self) { subPage in
switch subPage {
case .profile:
ProfileView()
}
}
.toolbar(viewModel.tabBarVisibility, for: .tabBar)
.animation(.default, value: viewModel.tabBarVisibility)
}
}
}

// MARK: HomeCoordinatorView+SubPage

extension HomeCoordinatorView {
/// An enumeration representing possible SubPages hosted by the ``HomeCoordinatorView``.
enum SubPage: Identifiable {
case profile

var id: Self { self }
}
}

A HomeCoordinatorView’s ViewModel…

…With its navigation state variable (pageStack) and ViewModel factory methods for making the ViewModels of the Views that are part of HomeViewCoordinator’s navigation flow.

import SwiftUI

extension HomeCoordinatorView {
@MainActor
final class ViewModel: ObservableObject {
enum Action {
case didTapSettings
case didTapAbout
}

var onAction: ((Action) -> Void)?

@Published var tabBarVisibility: Visibility = .visible
@Published var pageStack: [SubPage] = [] {
didSet {
switch pageStack.last {
case .none:
tabBarVisibility = .visible
case .profile:
tabBarVisibility = .hidden
}
}
}

// ...
}
}

extension HomeCoordinatorView.ViewModel {
func makeHomeViewModel() -> HomeView.ViewModel {
HomeView.ViewModel { [weak self] action in
switch action {
case .didTapProfile:
self?.pageStack.append(.profile)
case .didTapSettings:
self?.onAction?(.didTapSettings)
case .didTapAbout:
self?.onAction?(.didTapAbout)
}
}
}
}

A HomeView…

…And its Action emitting ViewModel that informs its closest CoordinatorView’s ViewModel about important events.

import SwiftUI

struct HomeView: View {
@StateObject var viewModel: ViewModel

var body: some View {
ScrollView {
Text("Welcome Home")
.padding()

Button("View Profile") {
viewModel.didTapProfile()
}
.padding()
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Settings", systemImage: "gear") {
viewModel.didTapSettings()
}

Button("About", systemImage: "info") {
viewModel.didTapAbout()
}
}
}
.navigationTitle("Home")
}
}

// MARK: HomeView+ViewModel

extension HomeView {
@MainActor
final class ViewModel: ObservableObject {
enum Action {
case didTapProfile
case didTapSettings
case didTapAbout
}

var onAction: ((Action) -> Void)?

// ...
}
}

extension HomeView.ViewModel {
func didTapProfile() {
// If one wanted, one could do some asynchronous work here before calling onAction.
onAction?(.didTapProfile)
}

func didTapSettings() {
// If one wanted, one could do some asynchronous work here before calling onAction.
onAction?(.didTapSettings)
}

func didTapAbout() {
// If one wanted, one could do some asynchronous work here before calling onAction.
onAction?(.didTapAbout)
}
}

A quick comment about dependencies

Dependencies (“Services”, “Repositories”, “Managers”, that kind of stuff) would typically be propagated throughout the “navigation graph” by injecting them into relevant ViewModels.

import SwiftUI

final class Dependencies {
let profileService: ProfileService = DefaultProfileService()
// ...
}

struct ContentView: View {
@State private var dependencies = Dependencies()

var body: some View {
MainCoordinatorView(
viewModel: MainCoordinatorView.ViewModel(dependencies: dependencies)
)
}
}

// ...

extension MainCoordinatorView {
@MainActor
final class ViewModel: ObservableObject {
private let dependencies: Dependencies // CoordinatorViews can access all dependencies.
// ...
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
// ...
func makeHomeCoordinatorViewModel() -> HomeCoordinatorView.ViewModel {
HomeCoordinatorView.ViewModel(dependencies: dependencies) { [weak self] action in
switch action {
case .didTapSettings:
self?.presentedSheet = .settings
}
}
}
// ...
}
}

// ...

extension HomeCoordinatorView {
@MainActor
final class ViewModel: ObservableObject {
private let dependencies: Dependencies // CoordinatorViews can access all dependencies.
// ...
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
// ...
func makeProfileViewModel() -> ProfileView.ViewModel {
// Non-CoordinatorViews only get to access the dependencies they explicitly need.
ProfileView.ViewModel(profileService: dependencies.profileService)
}
// ...
}
}

Final words

There it is! What do you think? 🤔

As a general rule of thumb though, it is probably a good idea to keep navigation in SwiftUI as simple as possible — for as long as possible. And use the stuff Apple has given us “out of the box”.

Happy coding! 😄

--

--