How to use Coordinator Pattern in SwiftUI
Jul 25, 2024Ever worked on a SwiftUI project and faced difficulty in handling the navigation flow of the application?
What if there's a pattern that could help you manage the view flow logic as well as make the codebase more clean and easy to understand? In this article, you'll learn exactly that with the Coordinator Pattern which solves this navigation flow problem.
Table of Content
- What is Coordinator Pattern
- Why Coordinator Pattern in SwiftUI
- How to use Coordinator Pattern in SwiftUI
What is Coordinator Pattern
Coordinator Pattern is a navigation flow pattern in mobile app development. It was originally presented by Soroush Kanlou in 2015 at NSSpain conference. This pattern separates the responsibility of navigation flow logic in an application by replacing the conventional dependency on NavigationStack
with Coordinator classes.
Why Coordinator Pattern in SwiftUI?
A SwiftUI application generally has all the flow logic handled by the NavigationStack
. NavigationStack
offer APIs that are way more efficient than NavigationView
APIs but when you are working on an application that has multiple navigation flow like Authentication flow, Profile Settings Flow, Create a Post flow etc, the navigation logic eventually gets mixed with views and event logic.
That's where Coordinator pattern comes in the picture. It completely decouples the navigation flow logic from application's view logic and increase the reusability and testability of the codebase.
How to use Coordinator Pattern in SwiftUI
Let's learn how to implement coordinator pattern in a basic SwiftUI app. We'll take an app that has a user login flow.
Implementing the Coordinator pattern in this scenario will include following steps:
Step 1. Create the type of Views that will be used in the Navigation flow of the application. For example, LoginView
, SignupView
, ForgotPasswordView
etc.
Step 2. Create an enum that includes the type of Views created in the first step as it's cases.
Step 3. Create a Coordinator class that handles the navigation flow logic and decide, which View will be rendered when a navigation event is triggered.
Step 4. Create a CoordinatorView
, this will be a SwiftUI view that will be set as the entry point of the application. This view will connect the Coordinator class logic with NavigationStack
, .fullScreenCover
and .sheet
APIs. CoordinatorView
will also set the root view for the NavigationStack
.
Step 5. Inject the Coordinator
in the NavigationStack
as an @EnvironmentObject
and use the coordinator in any View under the Stack to handle navigation.
Since the focus of this article is not on how to build the Views but rather on how to build the logic for the navigation between Views, so you can take a look at the project to get an idea about the views and then follow the next steps.
Let's first create an enum
in order to easily put switch case over the type of views that will be used in the navigation flow, wherever required.
enum AppPages: Hashable {
case main
case login
}
enum Sheet: String, Identifiable {
var id: String {
self.rawValue
}
case forgotPassword
}
enum FullScreenCover: String, Identifiable {
var id: String {
self.rawValue
}
case signup
}
You must be wondering why does our enums conform to the Hashable
or Identifiable
protocol? Well, your doubt is obvious, this enum will be passed around as a parameter in NavigationStack APIs or .sheet
or .fullScreenCover
APIs that strictly requires the object to conform to Hashable and Identifiable protocol respectively.
Now, we'll create the Coordinator
class that conforms to ObservableObject
. This class will handle the navigation flow logic of the application. We'll manage the events like transitioning from one view to another, showing a sheet view or presenting a full screen cover etc. through this Coordinator
class.
class Coordinator: ObservableObject {
@Published var path: NavigationPath = NavigationPath()
@Published var sheet: Sheet?
@Published var fullScreenCover: FullScreenCover?
}
We created three @Published
variables, the path variable will help in tracking the number of views stacked in the NavigationStack
. This path variable will be binded with the path parameter of NavigationStack
. The sheet
and fullScreenCover
variables will bind with their respective modifiers in SwiftUI.
Now, we'll write the methods to make the transition between the views and while presenting sheet or fullScreenCover. We'll also handle the dismissal of these sheets and the case, if the user wants to navigate from say, View C to View A(root view) directly.
class Coordinator: ObservableObject {
@Published var path: NavigationPath = NavigationPath()
@Published var sheet: Sheet?
@Published var fullScreenCover: FullScreenCover?
func push(page: AppPages) {
path.append(page)
}
func pop() {
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func presentSheet(_ sheet: Sheet) {
self.sheet = sheet
}
func presentFullScreenCover(_ cover: FullScreenCover) {
self.fullScreenCover = cover
}
func dismissSheet() {
self.sheet = nil
}
func dismissCover() {
self.fullScreenCover = nil
}
}
Let's understand the use of each one of the methods:
-
func push(page: AppPages) - will append the views in the navigation stack.
-
func pop() - will remove the last added view from the navigation stack.
-
func popToRoot() - will remove all the appended views from the navigation stack and directly takes the user to root view.
-
func presentSheet(sheet: Sheet) - will present the sheet view based on the type of sheet param.
-
func presentCover(cover: FullScreenCover) - will present the full screen cover based on the type of cover param.
-
func dismissSheet() - will handle the dismiss logic of the sheet view.
-
func dismissCover() - will handle the dismiss logic of the full screen cover.
These methods are handling the core presentation logic. But we haven't yet defined which will be shown when let's say, push(page: AppPages)
method will be called.
Therefore, we'll write the method that will manage which view will be shown for a given state.
@ViewBuilder
func build(page: AppPages) -> some View {
switch page {
case .main: WalkthroughView()
case .login: LoginView()
}
}
@ViewBuilder
func buildSheet(sheet: Sheet) -> some View {
switch sheet {
case .forgotPassword: ForgotPasswordView()
}
}
@ViewBuilder
func buildCover(cover: FullScreenCover) -> some View {
switch cover {
case .signup: SignupView()
}
}
The build()
methods handles, which View needs to be rendered for a given parameter.
Your Coordinator
is now ready. The next step is to create the CoordinatorView
that will handle the connection between Coordinator
class and NavigationStack
.
struct CoordinatorView: View {
@StateObject private var coordinator = Coordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
coordinator.build(page: .main)
.navigationDestination(for: AppPages.self) { page in
coordinator.build(page: page)
}
.sheet(item: $coordinator.sheet) { sheet in
coordinator.buildSheet(sheet: sheet)
}
.fullScreenCover(item: $coordinator.fullScreenCover) { item in
coordinator.buildCover(cover: item)
}
}
.environmentObject(coordinator)
}
}
Initialise the NavigationStack
in the CoordinatorView
and set the root view to WalkthroughView
. Likewise, initialise .sheet
API and .fullScreenCover
API to bind the coordinator variables with them and set the view that will be presented when a certain type of sheet or cover is passed as a parameter in coordinator's build()
method.
It's time for the final step to setup the coordinator pattern across the module -- inject the coordinator object as an environmentObject
in the NavigationStack.
Note : You can have multiple coordinators in the same project for different navigation flows in the application.
Set CoordinatorView
as the main entry point in the application. Open CoordinatorApp.swift file and add CoordinatorView
under WindowGroup
.
@main
struct CoordinatorApp: App {
var body: some Scene {
WindowGroup {
CoordinatorView()
}
}
}
Now, let's play with the navigation events in the application using our newly setup coordinator pattern.
The WalkthroughView
has a "Get Started" button that takes the user to LoginView
.
struct WalkthroughView: View {
@EnvironmentObject private var coordinator: Coordinator
var body: some View {
VStack {
Spacer()
Button {
coordinator.push(page: .login)
} label: {
Text("Get Started")
.font(.title3)
.foregroundStyle(.white)
.padding(16)
}
.frame(maxWidth: .infinity)
.background(Color.blue)
.clipShape(.buttonBorder)
}
.padding(16)
}
}
Create the @EnvironmentObject
of coordinator in the view and use the push()
method.
Let's build and run the application to check, how it works.
Click here to download the complete project
Result:
The LoginView
has ForgotPassword and Signup button. We will use ForgotPasswordView
as a sheet and SignupView
as full screen cover.
struct LoginView: View {
@EnvironmentObject private var coordinator: Coordinator
@State private var username: String = ""
@State private var password: String = ""
@State private var isSecure: Bool = true
var body: some View {
VStack {
// Create the TextFields to enter username and password here..
Button(action: {
// Handle login action here
}) {
Text("Login")
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
.padding(.horizontal, 30)
}
.padding(.top, 30)
Spacer()
VStack {
Button(action: {
coordinator.presentSheet(.forgotPassword)
}) {
Text("Forgot Password?")
.foregroundColor(.blue)
}
.padding(.bottom, 10)
HStack {
Text("Don't have an account?")
Button(action: {
coordinator.presentFullScreenCover(.signup)
}) {
Text("Sign Up")
.foregroundColor(.blue)
}
}
.padding(.bottom, 20)
}
}
.background(Color(.systemGroupedBackground))
.edgesIgnoringSafeArea(.all)
}
}
The navigation flow logic has become simple, just create an @EnvironmentObject
of coordinator and call the methods. To take a look at the complete project, click on the link below.
Click here to download the complete project
Here's how the app will eventually look like.
Result:
The Coordinator Pattern has completely separated the navigation logic from the Views and made it much easier to call the navigation methods from anywhere in the application. We'll write more articles on this concept and talk about:
- How to handle multiple coordinator in the same project
- Handle navigation while implementing deeplinking in the application
- How to use dependency injection and MVVM with Coordinator Pattern
Where to go next?
Congratulations, you have successfully learned how to implement Coordinator Pattern in a SwiftUI application. We would recommend you to learn more about MVVM in SwiftUI with Dependency Injection.
Want to master core design patterns in Swift? Checkout our new course on mobile system design that covers each and every aspect of low level design concepts. Learn more