Mastering Navigation in SwiftUI Using Coordinator Pattern

Diki Dwi Diro
6 min readJul 27, 2024

--

Source Image: https://developer.apple.com/wwdc22/10001

Navigating between views in SwiftUI can be simple and intuitive, thanks to the powerful NavigationStack that is introduced in WWDC 2022. This powerful API provides a seamless way to manage view transitions and maintain a history of navigation states. However, as applications grow in complexity, navigation can become challenging, especially with nested views and tightly coupled screens. When one screen’s navigation depends heavily on the previous one, it complicates the management of the navigation state and also passing data between them.

For this case the Coordinator Pattern emerges as a useful solution to separate navigation logic and increase the modularity of our code. In this brief publication, we will examine the Coordinator Pattern and its implementation in SwiftUI by fully utilize NavigationStack.

What is Coordinator Pattern?

The Coordinator Pattern is a design approach that centralizes and manages the navigation flow in an application. It promotes a clean codebase by isolating navigation logic, allowing view to focus on their specific tasks.

Implementation in SwiftUI

So let’s dive into the sample code to fully grasp the concept. In this example, we are going to have sample project app that will show lists of habit which will have total 5 pages (HomeView, ListHabitView, DetailHabitView, AddHabitView, and DetailTaskView) to cover all SwiftUI modifier for navigation and transition style.

  1. Define Presentation/Navigation Style

First thing first, we can start categorizing those pages with built-in SwiftUI presentation style like, Stack/Push, Sheet, or Full Screen Cover. We can achieve this by defining some enums to provide a clear and type-safe way to handle different states and transitions, ensuring that only valid pages or presentation types are used. Each enum represents a different type of presentation or navigation state.

enum Screen: Identifiable, Hashable {
case home
case listHabit
case detailHabit(named: Habit)

var id: Self { return self }
}

enum Sheet: Identifiable, Hashable {
case detailTask(named: Task)

var id: Self { return self }
}

enum FullScreenCover: Identifiable, Hashable {
case addHabit(onSaveButtonTap: ((Habit) -> Void))

var id: Self { return self }
}

extension FullScreenCover {
// Conform to Hashable
func hash(into hasher: inout Hasher) {
switch self {
case .addHabit:
hasher.combine("addHabit")
}
}

// Conform to Equatable
static func == (lhs: FullScreenCover, rhs: FullScreenCover) -> Bool {
switch (lhs, rhs) {
case (.addHabit, .addHabit):
return true
}
}
}

2. Define Coordinator Protocol

After that, let’s define a protocol AppCoordinatorProtocol to ensures a consistent approach to navigation across the app and also enables the reusability of our code:

protocol AppCoordinatorProtocol: ObservableObject {
var path: NavigationPath { get set }
var sheet: Sheet? { get set }
var fullScreenCover: FullScreenCover? { get set }

func push(_ screen: Screen)
func presentSheet(_ sheet: Sheet)
func presentFullScreenCover(_ fullScreenCover: FullScreenCover)
func pop()
func popToRoot()
func dismissSheet()
func dismissFullScreenOver()
}

In the AppCoordinatorProtocol:

  • path manages the navigation path stack of our NavigationStack later.
  • sheet and fullScreenCover manage the currently presented sheet and full-screen cover, respectively.
  • The push(_:), present(_:), and fullScreenCover(_:) functions handle the navigation and presentation of screens, sheets, and full-screen covers.
  • The pop(), popToRoot(), dismissSheet(), and dismissFullScreenOver() functions handle the dismissal of screens and modals.

3. Implement The Coordinator Protocol

Next step, create a class that conform to protocol that we have defined before which is AppCoordinatorProtocol so that we can use the concrete implementation of the protocol in our project sample app. It uses the @Published property wrapper to make changes observable, ensuring that SwiftUI views can react to updates in the navigation and presentation state.

class AppCoordinatorImpl: AppCoordinatorProtocol {
@Published var path: NavigationPath = NavigationPath()
@Published var sheet: Sheet?
@Published var fullScreenCover: FullScreenCover?

// MARK: - Navigation Functions
func push(_ screen: Screen) {
path.append(screen)
}

func presentSheet(_ sheet: Sheet) {
self.sheet = sheet
}

func presentFullScreenCover(_ fullScreenCover: FullScreenCover) {
self.fullScreenCover = fullScreenCover
}

func pop() {
path.removeLast()
}

func popToRoot() {
path.removeLast(path.count)
}

func dismissSheet() {
self.sheet = nil
}

func dismissFullScreenOver() {
self.fullScreenCover = nil
}

// MARK: - Presentation Style Providers
@ViewBuilder
func build(_ screen: Screen) -> some View {
switch screen {
case .home:
HomeView()
case .listHabit:
ListHabitView()
case .detailHabit(named: let habit):
DetailHabitView(habit: habit)
}
}

@ViewBuilder
func build(_ sheet: Sheet) -> some View {
switch sheet {
case .detailTask(named: let task):
DetailTaskView(task: task)
}
}

@ViewBuilder
func build(_ fullScreenCover: FullScreenCover) -> some View {
switch fullScreenCover {
case .addHabit(onSaveButtonTap: let onSaveButtonTap):
AddHabitView(onSaveButtonTap: onSaveButtonTap)
}
}
}

At the bottom section of AppCoordinatorImpl we can see The “Presentation Style Providers” section of at is responsible for generating the appropriate views based on different application states, such as navigation screens, modal sheets, and full-screen covers. By using @ViewBuilder, these functions can conditionally create and return the correct views based on the state, which helps in organizing and managing the user interface in a modular and maintainable way.

This approach centralizes view creation logic in one place, making it easier to manage how and when different views are presented within the app.

(See the link below to download the complete version of the project)

4. Create Coordinator’s Container View

Let’s create a view for our coordinator in a view called CoordinatorView for managing navigation and modal presentations. It serves as the entry point for the navigation flow and presentation styles within the app. It initializes AppCoordinatorImpl as a @StateObject and pass it to the child view by using .environmentObject modifier.

struct CoordinatorView: View {
@StateObject var appCoordinator: AppCoordinatorImpl = AppCoordinatorImpl()

var body: some View {
NavigationStack(path: $appCoordinator.path) {
appCoordinator.build(.home)
.navigationDestination(for: Screen.self) { screen in
appCoordinator.build(screen)
}
.sheet(item: $appCoordinator.sheet) { sheet in
appCoordinator.build(sheet)
}
.fullScreenCover(item: $appCoordinator.fullScreenCover) { fullScreenCover in
appCoordinator.build(fullScreenCover)
}
}
.environmentObject(appCoordinator)
}
}

This is where we centralized ourNavigationStack within the app.

At its core, CoordinatorView initializes an AppCoordinatorImpl instance using the @StateObject property wrapper. This ensures that the coordinator is properly managed throughout the view's lifecycle and allows it to observe and react to changes. The NavigationStack integrates with the coordinator to handle the navigation path, setting the initial view to HomeView by calling appCoordinator.build(.home).

As users navigate through the app, CoordinatorView uses the navigationDestination(for:) modifier to manage transitions between screens based on the Screen enum values. This ensures that the appropriate views are displayed as users move through different parts of the app. Additionally, the sheet(item:) modifier presents modal sheets when appCoordinator.sheet is set, and the fullScreenCover(item:) modifier handles full-screen modals based on appCoordinator.fullScreenCover.

By providing appCoordinator as an environment object through .environmentObject(appCoordinator), CoordinatorView allows all child views to access the coordinator, facilitating consistent navigation and presentation logic across the app.

5. Use Coordinator in a View

In order to use coordinator in a view, we can create a variable with type of AppCoordinatorImpl with property wrapper @EnvironmentObject

@EnvironmentObject var appCoordinator: AppCoordinatorImpl

After that, we can use that variable to navigate between view/page that we have defined before in AppCoordinatorImpl easily.

Example:

  • In this case, I want to navigate fromHomeView to ListHabitView with a trigger of button. I can just type appCoordinator.push(.listHabit)
struct HomeView: View {
@EnvironmentObject var appCoordinator: AppCoordinatorImpl

var body: some View {
VStack {

Spacer()

Image(systemName: "house")
.foregroundStyle(.tint)
.imageScale(.large)

Text("Home")

Spacer()

Button("Go to Habit List") {
appCoordinator.push(.listHabit)
}
}
.navigationTitle("Home")

}
}
  • What if… I want to present DetailTaskView with modal sheet from DetailHabitView All I have to do is just type appCoordinator.presentSheet(.detailTask(named: task)) and the sheet will automatically present on the screen!
struct DetailHabitView: View {
@EnvironmentObject var appCoordinator: AppCoordinatorImpl

let habit: Habit

var body: some View {
VStack {
List(habit.tasks) { task in
Button {
appCoordinator.presentSheet(.detailTask(named: task))
} label: {
Text(task.title)
}
}
}
.navigationTitle(habit.title)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Home") {
appCoordinator.popToRoot()
}
}
}
}
}

The page still smoothly navigate from one to another! and here is the preview of the sample app using the Coordinator Pattern:

Smoothly navigate between views using Coordinator Pattern

Complete Version of Sample Project

Conclusion

There you have it 🥳! as you can see at the two example above, how easy it is to navigate between view/page withNavigationStack in SwiftUI without making the screen tightly coupled each other.

So, what’s the matter of implementing this concept? here is the short summary:

  • The Coordinator Pattern separates navigation logic in a SwiftUI View which means increased modularity.
  • Coordinators can be reused in different parts of the application.
  • Navigation logic becomes easier to test, as coordinators can be tested independently.

I recognize that this approach may have its imperfections. Please feel free to share any feedback or suggestions you might have about it!

Until next time!

--

--