| name | coordinator-router-ios |
| description | Guidance for implementing the Coordinator + Router navigation pattern in UIKit iOS projects. Covers Coordinator protocol, Router (wrapping UINavigationController), child coordinator lifecycle, Presentable abstraction, and ModulesFactory patterns. |
Coordinator + Router Pattern for iOS
A navigation architecture that decouples view controllers from navigation logic by extracting routing into a Router object and flow orchestration into Coordinator objects.
Core Components
Coordinator protocol
protocol Coordinator: AnyObject {
var router: Router { get }
var children: [Coordinator] { get set }
func start()
}
Extension methods for child management:
addDependency(_:) — stores a child coordinator in children. Without this the child deallocates.
removeDependency(_:) — recursively removes a child and its descendants.
removeAll() — dismisses presented modules and removes all children (used for full resets).
Router
Wraps UINavigationController. Standard API:
| Method | Behavior |
|---|
setRootModule(_:) | Replace the entire navigation stack |
push(_:animated:) | Push onto the stack. Assert if module is a UINavigationController |
popModule(animated:) | Pop top view controller |
present(_:animated:modalPresentationStyle:) | Present modally |
dismissModule(animated:) | Dismiss presented controller |
The Router also conforms to UINavigationControllerDelegate to run completion callbacks when a view controller is popped interactively.
Presentable protocol
protocol Presentable: AnyObject {
func toPresent() -> UIViewController
}
UIViewController conforms by default (returns self). The Router also conforms (returns its navigationController), allowing the Router to be passed as a module argument.
ModulesFactory
A single class, extended per-flow via protocol conformance:
protocol AuthModulesFactory: AnyObject {
func makeLogin() -> Presentable & LoginViewOutput
}
extension ModulesFactory: AuthModulesFactory {
func makeLogin() -> Presentable & LoginViewOutput {
LoginViewController()
}
}
Flow Wiring
Parent creates child, hooks output, stores it
func runAuthFlow() {
let coordinator = coordinatorsFactory.makeAuth(with: router)
coordinator.onFinish = { [weak self, weak coordinator] in
self?.removeDependency(coordinator)
self?.runMainFlow()
}
addDependency(coordinator)
coordinator.start()
}
Rules:
onFinish closures use [weak self, weak coordinator] to avoid retain cycles.
- Child coordinators receive the same
router to share the navigation stack.
- Never pass a
UINavigationController to router.push() — use a separate Router per stack.
ViewController communicates back to Coordinator via onFinish
Each module declares a ViewOutput protocol:
protocol LoginViewOutput: AnyObject {
var onFinish: ((Bool) -> Void)? { get set }
}
The Coordinator assigns this closure. The ViewController calls it on user action (button tap, success, etc.).
The ViewController acts as a mediator:
View (SnapKit, delegate) ← ViewController (onFinish + View delegate) → Coordinator (navigation)
- View owns subviews and layout. Declares a delegate protocol for user actions.
- ViewController sets
view = mainView in loadView(). Conforms to ViewOutput and the View's delegate.
- Coordinator decides what to do next (push, present, swap flows, start child coordinator).
Flow output protocol
Child coordinators expose an onFinish via a dedicated output protocol:
protocol AuthCoordinatorOutput: AnyObject {
var onFinish: (() -> Void)? { get set }
}
Factory methods should return the protocol composition (Coordinator & AuthCoordinatorOutput) so the parent only sees the output, not internal API.
Tab Bar Setup
Each tab gets its own UINavigationController, own Router, and own child coordinator:
private func makeHomeTab() -> UIViewController {
let nc = UINavigationController()
nc.tabBarItem = UITabBarItem(title: "Home", image: ..., selectedImage: ...)
let coordinator = coordinatorsFactory.makeHome(with: Router(with: nc))
addDependency(coordinator)
coordinator.start()
return nc
}
The TabBarCoordinator creates all tabs and sets the tab bar as root module.
Child Coordinator Lifecycle
Parent Coordinator
├─ child.start()
├─ child.onFinish = { remove + next step }
├─ addDependency(child) // keeps child alive
└─ child finishes → onFinish fires → removeDependency → child deallocates
For flows that push their own child coordinators (e.g., tapping a doctor opens a hospital coordinator), inject CoordinatorsFactory into the child coordinator so it can create its own children.
When to Use
- Multiple flows that share a navigation stack.
- Deep linking or complex conditional routing (auth → onboarding → main).
- Flows that need to be reusable (same auth flow used from multiple entry points).
- Testing navigation in isolation by injecting a mock Router.
When to Avoid
- A single-screen app with no flow changes.
- SwiftUI projects (use
NavigationStack + path-based navigation instead).
- Projects where coordinators are simpler than the view controllers they manage.