Mastering Navigation in SwiftUI Using Coordinator Pattern
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.
- 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 ourNavigationStack
later.sheet
andfullScreenCover
manage the currently presented sheet and full-screen cover, respectively.- The
push(_:)
,present(_:)
, andfullScreenCover(_:)
functions handle the navigation and presentation of screens, sheets, and full-screen covers. - The
pop()
,popToRoot()
,dismissSheet()
, anddismissFullScreenOver()
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 from
HomeView
toListHabitView
with a trigger of button. I can just typeappCoordinator.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 fromDetailHabitView
All I have to do is just typeappCoordinator.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:
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!