A coordinator is a very powerful pattern that helps you control your flow and know what your current flow is at any time without exposing any classes you don’t want.
With a coordinator, your app flow can be controlled by the server. Does it sound like a product manager’s dream: A/B test on app flow to understand which flow works best for the app with minimal development effort?
When your app receives a push notification or opened by a deep link you can know exactly where the user is and get a reference to all relevant view models to decide what to do without exposing even the names of your views or the names of your view models to the app.
Encapsulation of the navigation logic, so you can create unit tests for your navigation logic.
There are a lot of very good and interesting articles about the coordinator and its advantages and disadvantages in the network, from my experience: a lot of advantages and only one disadvantage that I will come back to it later because before that you need to understand how it works, so let’s get started.
You probably already know what is MVVM so the coordinator is just a layer above your view model that contains all navigation logic of the app or in other words: encapsulation of the navigation logic to a separate class. When it is used in MVVM it called MVVMC(Model View View Model Coordinator) and looks like this:
The coordinator also has a hierarchy, which means that a coordinator may have child coordinators. Child coordinator created for each new flow, for example, before our app starts we create an AppCoordinator and he is checking if the user is logged in, if the user is logged in then the AppCoordinator starts its child coordinator MainFlowCoordinator otherwise it starts LoginFlowCoordinator:
LoginCoordinator is responsible to create the first view and navigate between them(sign in, sign up, terms of use, etc), when the user is able to login successfully, the event of the LoginFlowCoordinator fires “onFinished” and the AppCoordinator starts its child coordinator MainFlowCoordinator.
Now let’s see how it looks in the code with SwiftUI and Combine.
First of all, let’s create a Coordinator protocol:
import Combine import SwiftUI class CoordinatorComponents { fileprivate(set) var childCoordinators = [Coordinator]() fileprivate(set) var views = [ViewIdentifier]() } protocol Coordinator: class { var coordinatorComponents: CoordinatorComponents { get } var onFinished: PassthroughSubject<Void, Never> { get } var destination: AnyView? { get } } extension Coordinator { func add(childCoordinator coordinator: Coordinator) { coordinatorComponents.childCoordinators.append(coordinator) } func remove(childCoordinator coordinator: Coordinator) { coordinatorComponents.childCoordinators = coordinatorComponents.childCoordinators.filter {$0 !== coordinator} } func add(view: ViewIdentifier) { coordinatorComponents.views.append(view) } func remove(view: ViewIdentifier) { coordinatorComponents.views = coordinatorComponents.views.filter {$0 != view} } }
To know exactly our current flow we need two arrays to store the information in, “child coordinators” and “views”. Inside the protocol, I created “CoordinatorComponents” for the storage, and protocol’s extension for the “add” and the “remove” functionality, otherwise, we will need to copy/paste this code for each of our coordinators.
The ViewIdentifier looks like this:
enum ViewIdentifier: Equatable { case main(MainViewModel) case login(LoginViewModel) static func == (lhs: ViewIdentifier, rhs: ViewIdentifier) -> Bool { switch (lhs, rhs) { case (let .main(lhsVm), let .main(rhsVm)): return lhsVm.id == rhsVm.id case (let .login(lhsVm), let .login(rhsVm)): return lhsVm.id == rhsVm.id default: return false } } }
Now let’s create AppCoordinator:
import SwiftUI import Combine class AppCoordinator: Coordinator, ObservableObject { @Published private(set) var destination: AnyView? let coordinatorComponents = CoordinatorComponents() let onFinished: PassthroughSubject<Void, Never> = .init() private let isUserLoggedIn = false private var cancelables = Set<AnyCancellable>() init() { if isUserLoggedIn { let mainCoordinator = MainFlowCoordinator() add(childCoordinator: mainCoordinator) destination = mainCoordinator.destination } else { let loginCoordinator = LoginFlowCoordinator() subscribeToChanges(loginCoordinator: loginCoordinator) add(childCoordinator: loginCoordinator) destination = loginCoordinator.destination } } } private extension AppCoordinator { func subscribeToChanges(loginCoordinator: LoginFlowCoordinator) { loginCoordinator.onFinished.sink { [weak self, weak loginCoordinator] (_) in guard let self = self, let loginCoordinator = loginCoordinator else { return } self.remove(childCoordinator: loginCoordinator) let mainCoordinator = MainFlowCoordinator() self.add(childCoordinator: mainCoordinator) self.destination = mainCoordinator.destination }.store(in: &loginCoordinator.cancelables) } }
You can see the logic inside the init function that I mentioned before. Just to illustrate to you the logic easily isLoggedIn property is just a bool but in the real app, your AppCoordinator should have reference to all of your models like a repository, request manager, database manager, etc., and request the current user info from where it needed.
It is also a huge advantage to use dependency injection principles here, all your views and view models are created in the coordinator so the coordinator will inject only needed dependencies to a specific view model without exposing them all over the world by creating a singleton.
So our app starts in @main like this:
import SwiftUI @main struct SwftUICoordinatorApp: App { @StateObject var coordinator = AppCoordinator() var body: some Scene { WindowGroup { coordinator.destination } } }
As you can see the @main does not do any logic, even doesn’t have any information about the destination view, of course, it is not its responsibility, its responsibility is just to present and change the main view when needed.
Let’s move on, the LoginFlowCoordinator:
import Combine import SwiftUI class LoginFlowCoordinator: Coordinator, ObservableObject { @Published private(set) var destination: AnyView? let coordinatorComponents = CoordinatorComponents() let onFinished: PassthroughSubject<Void, Never> = .init() var cancelables = Set<AnyCancellable>() init() { let viewModel = LoginViewModel() let loginView = createLoginView(viewModel: viewModel) add(view: .login(viewModel)) destination = AnyView(loginView) } } private extension LoginFlowCoordinator { func createLoginView(viewModel: LoginViewModel)->LoginView { let loginView = LoginView(viewModel: viewModel) subscribeToChanges(viewModel: viewModel) return loginView } func subscribeToChanges(viewModel: LoginViewModel) { viewModel.userIsLoggedIn .sink { [weak self] (isLoggedIn) in guard isLoggedIn == true else { return } self?.remove(view: .login(viewModel)) self?.onFinished.send() }.store(in: &cancelables) } }
Now we got to the part when I can tell you about the biggest coordinator’s disadvantage. As you can see anytime we creating a child coordinator or navigating to a view we adding it to the coordinator’s storage to store our current flow position and also for a strong pointer to the child coordinator, otherwise, he will be deallocated by the ARC.
But what will happen if some developer will forget to “add” or to “remove” child coordinator or/and view? Well, big power = big responsibilities we must be very careful at these places when developing and code reviewing, and most important we must create unit tests to avoid this. If you want to know how we can test the coordinator, stay tuned I will write an article about it soon.
MainFlowCoordinator will be very similar to LoginFlowCoordinator but with other viewModel properties like userProfileButtonTapped so the concept is the same.
For this part a wrote another article that you can find here
You are welcome to clap if you like it.
7