How to use Coordinator Pattern in SwiftUI

mobile-system-design swiftui Jul 25, 2024
Coordinator Pattern in SwiftUI

Ever 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

  1. What is Coordinator Pattern
  2. Why Coordinator Pattern in SwiftUI
  3. 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:

  1. func push(page: AppPages) - will append the views in the navigation stack.

  2. func pop() - will remove the last added view from the navigation stack.

  3. func popToRoot() - will remove all the appended views from the navigation stack and directly takes the user to root view.

  4. func presentSheet(sheet: Sheet) - will present the sheet view based on the type of sheet param.

  5. func presentCover(cover: FullScreenCover) - will present the full screen cover based on the type of cover param.

  6. func dismissSheet() - will handle the dismiss logic of the sheet view.

  7. 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:

  1. How to handle multiple coordinator in the same project
  2. Handle navigation while implementing deeplinking in the application
  3. 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

Signup now to get notified about our
FREE iOS Workshops!