Last active
March 10, 2026 11:08
-
-
Save Coder-ACJHP/e424afbb77077ec27cce02b4da09d573 to your computer and use it in GitHub Desktop.
Coordinator Pattern in Swift — Decoupled Navigation with AppCoordinator & Child Coordinators
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MARK: - AppCoordinator | |
| // Root coordinator that owns and manages all child coordinator flows. | |
| // Acts as the single entry point for navigation in the app. | |
| // Implements AuthCoordinatorDelegate to receive cross-flow navigation requests | |
| // without tight coupling between child coordinators. | |
| class AppCoordinator: AuthCoordinatorDelegate { | |
| var navigationController: UINavigationController | |
| // Dictionary-based storage allows O(1) lookup and safe cleanup by tag (UUID) | |
| var childCoordinators: [String: any Coordinable] = [:] | |
| init(navigationController: UINavigationController) { | |
| self.navigationController = navigationController | |
| } | |
| @MainActor | |
| func start() { | |
| let authCoordinator = AuthCoordinator(navigationController: navigationController) | |
| // When a child finishes, it removes itself from the registry to avoid memory leaks | |
| authCoordinator.onFinish = { [weak self] tag in | |
| self?.childCoordinators.removeValue(forKey: tag) | |
| } | |
| // AppCoordinator listens to AuthCoordinator for upward navigation requests | |
| authCoordinator.delegate = self | |
| childCoordinators[authCoordinator.tag] = authCoordinator | |
| authCoordinator.startFlow() | |
| } | |
| @MainActor | |
| func requestSettingsFlow() { | |
| let settingsCoordinator = SettingsCoordinator(navigationController: navigationController) | |
| settingsCoordinator.onFinish = { [weak self] tag in | |
| self?.childCoordinators.removeValue(forKey: tag) | |
| } | |
| childCoordinators[settingsCoordinator.tag] = settingsCoordinator | |
| settingsCoordinator.startFlow() | |
| } | |
| } | |
| // MARK: - Coordinable Protocol | |
| // Defines the contract every coordinator must fulfill. | |
| // Enforces a consistent lifecycle: start → (navigate) → finish. | |
| @MainActor | |
| protocol Coordinable: AnyObject { | |
| var tag: String { get set } // Unique identifier (UUID) for registry lookup | |
| var onFinish: ((String) -> Void)? { get set } // Callback to notify parent when flow ends | |
| var navigationController: UINavigationController { get } | |
| func startFlow() | |
| func finishFlow() | |
| } | |
| // MARK: - Cross-Flow Communication Protocol | |
| // Allows a child coordinator (Auth) to request a sibling flow (Settings) | |
| // without holding a direct reference to AppCoordinator. | |
| // This breaks the tight coupling that would occur with direct parent references. | |
| @MainActor | |
| protocol AuthCoordinatorDelegate: AnyObject { | |
| func requestSettingsFlow() | |
| } | |
| // MARK: - AuthCoordinator | |
| // Owns the authentication flow: Login → OTP. | |
| // Communicates upward via delegate (to AppCoordinator) for cross-flow transitions. | |
| class AuthCoordinator: Coordinable { | |
| var tag: String = UUID().uuidString | |
| var onFinish: ((String) -> Void)? | |
| var navigationController: UINavigationController | |
| weak var delegate: AuthCoordinatorDelegate? // weak to avoid retain cycle with AppCoordinator | |
| init(navigationController: UINavigationController) { | |
| self.navigationController = navigationController | |
| } | |
| func startFlow() { | |
| let loginVC = LoginViewController() | |
| loginVC.coordinator = self | |
| self.navigationController.pushViewController(loginVC, animated: true) | |
| } | |
| func showOTP() { | |
| let oTPViewController = OTPViewController() | |
| oTPViewController.coordinator = self | |
| self.navigationController.pushViewController(oTPViewController, animated: true) | |
| } | |
| // Delegates upward instead of directly instantiating SettingsCoordinator, | |
| // keeping Auth flow isolated from Settings flow. | |
| func showSettings() { | |
| delegate?.requestSettingsFlow() | |
| } | |
| // Pops the current screen and signals AppCoordinator to release this coordinator | |
| func finishFlow() { | |
| self.navigationController.popViewController(animated: true) | |
| onFinish?(tag) | |
| } | |
| } | |
| // MARK: - ViewControllers (Auth Flow) | |
| // Each VC holds a weak reference to its coordinator to avoid retain cycles. | |
| // VCs only know about their immediate coordinator, never about AppCoordinator. | |
| class LoginViewController: UIViewController { | |
| weak var coordinator: AuthCoordinator? | |
| @objc | |
| func didTapToBack() { | |
| coordinator?.finishFlow() | |
| } | |
| } | |
| class OTPViewController: UIViewController { | |
| weak var coordinator: AuthCoordinator? | |
| @objc | |
| func didTapToBack() { | |
| navigationController?.popViewController(animated: true) | |
| } | |
| private func navigateToSettings() { | |
| // Bubbles up through coordinator instead of navigating directly | |
| coordinator?.showSettings() | |
| } | |
| } | |
| // MARK: - SettingsCoordinator | |
| // Owns the settings flow: Settings → Modal. | |
| // Completely isolated from AuthCoordinator — launched by AppCoordinator on request. | |
| class SettingsCoordinator: Coordinable { | |
| var tag: String = UUID().uuidString | |
| var onFinish: ((String) -> Void)? | |
| var navigationController: UINavigationController | |
| init(navigationController: UINavigationController) { | |
| self.navigationController = navigationController | |
| } | |
| func startFlow() { | |
| let settingsViewController = SettingsViewController() | |
| settingsViewController.coordinator = self | |
| self.navigationController.pushViewController(settingsViewController, animated: true) | |
| } | |
| func showModal() { | |
| let modalVC = ModalViewController() | |
| modalVC.modalTransitionStyle = .crossDissolve | |
| modalVC.modalPresentationStyle = .formSheet | |
| self.navigationController.present(modalVC, animated: true, completion: nil) | |
| } | |
| func finishFlow() { | |
| self.navigationController.popViewController(animated: true) | |
| onFinish?(tag) | |
| } | |
| } | |
| // MARK: - ViewControllers (Settings Flow) | |
| class SettingsViewController: UIViewController { | |
| weak var coordinator: SettingsCoordinator? | |
| @objc | |
| func didTapToBack() { | |
| coordinator?.finishFlow() | |
| } | |
| private func showModal() { | |
| coordinator?.showModal() | |
| } | |
| } | |
| // ModalViewController needs no coordinator reference: | |
| // it is the terminal screen of the flow and only dismisses itself. | |
| class ModalViewController: UIViewController { | |
| @objc | |
| func didTapToBack() { | |
| self.dismiss(animated: true) | |
| } | |
| } | |
| // MARK: - App Entry Point | |
| // AppCoordinator is created once and retained for the app's lifetime. | |
| // All navigation flows branch from this single root. | |
| let navController = UINavigationController() | |
| let coordinator = AppCoordinator(navigationController: navController) | |
| coordinator.start() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment