Coordinator Pattern for iOS Apps

Murat Emre Aygün
Appcent
Published in
6 min readDec 11, 2023

--

As you know in mobile applications we are frequently navigating between screens and while navigating we are basically getting the instance of the view controller that we want to navigate to:

class ViewController: UIViewController {
private func goToProductDetailScreen() {
let storyboard = UIStoryboard.init(name: "Main", bundle: nil)
guard let vc = storyboard.instantiateViewController(withIdentifier: "ProductDetailVC") as? ProductDetailVC else { return }
self.navigationController?.pushViewController(vc, animated: true)
}
}

So if we evaluate this code sample we can see that it satisfies our needs to go to the screen we want. But when we try to go to the same screen from different view controllers we should duplicate the code block and use it in multiple places. As the app grows we will encounter with these kind of situations more frequently and will use more of the duplicate code for navigation purposes. That means it will be more challenging to manage it as the complexity of the app grows.

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

Understanding the Coordinator Pattern

The Coordinator Pattern is a design pattern that aims to centralize and manage the navigation flow in an iOS application. It provides us a clean architecture by separating the responsibilities of view controllers, making them more focused on their specific tasks.

Implementation in Swift

So let’s dive into the Coordinator Pattern with an example. At our example we are going to have an app which has a tabbar controller with two tab items (Home and profile screens respectively).

First of all we should define a protocol as shown below to be used at our separated coordinator classes for different navigation flows:

import UIKit

protocol Coordinator: AnyObject {
/// Delegate to be triggered when a view controller's coordinator is disappearing to notify previous coordinator
var finishDelegate: CoordinatorFinishDelegate? { get set }
/// Each coordinator has one navigation controller assigned to it.
var navigationController: UINavigationController { get set }

/// Array to keep tracking of all child coordinators.
var childCoordinators: [Coordinator] { get set }

/// Defined flow type.
var type: CoordinatorType { get }

func start()

/// A place to put logic to finish the flow, to clean all children coordinators, and to notify the parent that this coordinator is ready to be deallocated
func finish()
}

extension Coordinator {
func finish() {
/// For default stuff for completely reseting all flow
childCoordinators.removeAll()
finishDelegate?.coordinatorDidFinish(childCoordinator: self)
}
}

// MARK: - CoordinatorOutput

protocol CoordinatorFinishDelegate: class {
func coordinatorDidFinish(childCoordinator: Coordinator)
}

// MARK: - CoordinatorType

enum CoordinatorType {
case app, mainTab, tabItem /// for our example we will only have this ones but normally as the project gets bigger we will use more screens which means more coordinators. So the more cases should be added in that scenario.
}

After defining the Coordinator protocol we should create a coordinator class which will be used as the initial coordinator at our application launch. This coordinator class will conform to a protocol that we defined above but with additional function. So we are creating a new protocol that inherits from our previously defined Coordinator protocol and use it for our new class:

import UIKit

protocol AppCoordinatorProtocol: Coordinator {
func showMainFlow()
}

class AppCoordinator: AppCoordinatorProtocol {
weak var finishDelegate: CoordinatorFinishDelegate?

var navigationController: UINavigationController

var childCoordinators = [Coordinator]()

var type: CoordinatorType { .app }

init(navigationController: UINavigationController) {
self.navigationController = navigationController
}

func start() {
self.showMainFlow()
}

func showMainFlow() {
let tabCoordinator = TabCoordinator(navigationController)
tabCoordinator.finishDelegate = self
tabCoordinator.start()
childCoordinators.append(tabCoordinator)
}
}

extension AppCoordinator: CoordinatorFinishDelegate {
func coordinatorDidFinish(childCoordinator: Coordinator) {
// Do additional stuff after the presented screen is dismissed
childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
}
}

And we will use this initial coordinator at our AppDelegate by defining an appCoordinator property and setting it in AppDelegate’s didFinishLaunchingWithOptions method:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var appCoordinator: AppCoordinator?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
appCoordinator = AppCoordinator(navigationController: UINavigationController())
appCoordinator?.start()

window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = navController
window?.makeKeyAndVisible()

return true
}

...
}

So to summarize we created a initial Coordinator which will handle the first navigation flow and showed a tabbar controller with triggering the showMainFlow() function. This function opens another coordinator for Tabbar arrangements which is named TabCoordinator:

enum TabBarPage {
case home
case profile

func getIndex() -> Int {
switch self {
case .home: return 0
case .profile: return 1
}
}

func getTitleName() -> String {
switch self {
case .home:
return "Home"
case .profile:
return "Profile"
}
}

func getIconName() -> String {
switch self {
case .home:
return "iconHome"
case .profile:
return "iconUser"
}
}
}

protocol TabCoordinatorProtocol: Coordinator {
var tabBarController: UITabBarController { get set }
func selectPage(_ page: TabBarPage)
func setSelectedIndex(_ index: Int)
func currentPage() -> TabBarPage?
}

class TabCoordinator: NSObject, Coordinator {
weak var finishDelegate: CoordinatorFinishDelegate?

var childCoordinators: [Coordinator] = []

var navigationController: UINavigationController

var tabBarController: TabBarController

var type: CoordinatorType { .mainTab }

init(navigationController: UINavigationController) {
self.navigationController = navigationController
tabBarController = UITabBarController()
}

func start() {
let pages: [TabBarPage] = [.home, .profile]

// Initialization of ViewControllers from pages
let controllers: [UINavigationController] = pages.map { getTabController($0) }

prepareTabBarController(withTabControllers: controllers)
}

private func getTabController(_ page: TabBarPage) -> UINavigationController {
let navController = UINavigationController()
navController.tabBarItem = UITabBarItem(title: page.getTitleName(),
image: UIImage(named: page.getIconName()),
tag: page.getIndex())
switch page {
case .home:
let coordinator = HomeCoordinator(navController)
coordinator.start()
childCoordinators.append(coordinator)
case .profile:
let coordinator = ProfileCoordinator(navController)
coordinator.start()
childCoordinators.append(coordinator)
}
return navController
}
private func prepareTabBarController(withTabControllers tabControllers: [UIViewController]) {
tabBarController.delegate = self
tabBarController.setViewControllers(tabControllers, animated: true)
tabBarController.selectedIndex = TabBarPage.home.getIndex()
tabBarController.tabBar.isTranslucent = false

/// In this step, we attach tabBarController to navigation controller associated with this coordanator
navigationController.viewControllers = [tabBarController]
}
}

Lastly, for this example we used only two tab items which are home and profile. So for them we are using another CoordinatorType which is .tabItem and here is how the coordinators for those items look:

class HomeCoordinator: Coordinator {
weak var finishDelegate: CoordinatorFinishDelegate?

var navigationController: UINavigationController

var childCoordinators: [Coordinator] = []

var type: CoordinatorType = .tabItem

var homeVC: HomeVC

init(navigationController: UINavigationController) {
self.navigationController = navigationController
homeVC = HomeVC.instantiate() // Suppose that we have an extension which has instantiate function for creating view controllers. At this publication I didn't show the content of it.
homeVC.delegate = self
}

func start() {
navigationController.pushViewController(homeVC, animated: true)
}
}

extension HomeCoordinator: CoordinatorFinishDelegate {
func coordinatorDidFinish(childCoordinator: Coordinator) {
childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
}
}

extension HomeCoordinator: HomeVCDelegate {
func goToProductDetail() {}
func goToProductList() {}
...
}
class ProfileCoordinator: Coordinator {
weak var finishDelegate: CoordinatorFinishDelegate?

var navigationController: UINavigationController

var childCoordinators: [Coordinator] = []

var type: CoordinatorType = .tabItem

var profileVC: ProfileVC

init(navigationController: UINavigationController) {
self.navigationController = navigationController
profileVC = ProfileVC.instantiate() // Suppose that we have an extension which has instantiate function for creating view controllers. At this publication I didn't show the content of it.
profileVC.delegate = self
}

func start() {
navigationController.pushViewController(vc, animated: false)
}
}

extension ProfileCoordinator: CoordinatorFinishDelegate {
func coordinatorDidFinish(childCoordinator: Coordinator) {
childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
}
}

extension ProfileCoordinator: ProfileVCDelegate {
func goToSettings() {}
func goToFavorites() {}
...
}

With these codes it can be said that we gave a short summary of how to implement Coordinator Pattern at an iOS Application. So what is the benefit of this?

  • The Coordinator Pattern separates navigation logic from view controllers which means increased modularity.
  • Code duplication in different view controllers for navigation flows become hindered.
  • Coordinators can be reused in different parts of the application.
  • Navigation logic becomes easier to test, as coordinators can be tested independently.
  • The pattern scales well with the growth of the application, making it easy to add and manage new features and flows

Conclusion

In conclusion, the Coordinator Pattern is a powerful tool for managing navigation in iOS applications, promoting a clean and modular architecture. By using different coordinators for distinct navigation flows, we can maintain a structured and scalable codebase. While there is a learning curve, the benefits of improved separation of concerns, reusability, and easy testing make the Coordinator Pattern a valuable addition to your iOS projects. So using the Coordinator Pattern allows us to develop more robust and maintainable iOS applications.

--

--