Loading...
Loading...
Expert Coordinator pattern decisions for iOS/tvOS: when coordinators add value vs overkill, parent-child coordinator hierarchy design, SwiftUI vs UIKit coordinator differences, and flow completion handling. Use when designing navigation architecture, implementing multi-step flows, or decoupling views from navigation. Trigger keywords: Coordinator, navigation, flow, parent coordinator, child coordinator, deep link, routing, navigation hierarchy, flow completion
npx skill4agent add kaakati/rails-enterprise-dev coordinator-patternHow many navigation flows does your app have?
├─ 1-2 simple flows
│ └─ Skip coordinators
│ NavigationStack + simple Router is enough
│
├─ 3-5 distinct flows
│ └─ Consider coordinators IF:
│ • Flows have complex branching
│ • Deep linking is required
│ • Flows need to share navigation logic
│
└─ 6+ flows or multi-team development
└─ Coordinators recommended
• Clear ownership boundaries
• Parallel development possible
• Testable navigation logicDoes this flow need to manage sub-flows?
├─ NO (leaf coordinator)
│ └─ Simple coordinator
│ Owns NavigationPath, creates views
│
└─ YES (has sub-flows)
└─ Parent coordinator
Manages childCoordinators array
Delegates to child for sub-flowsWhich UI framework?
├─ SwiftUI
│ └─ Coordinator as ObservableObject
│ • Owns NavigationPath
│ • @ViewBuilder for destinations
│ • Pass via EnvironmentObject or explicit injection
│
└─ UIKit
└─ Coordinator owns UINavigationController
• Creates and pushes ViewControllers
• Uses delegation for flow completion
• Manages childCoordinators manuallyHow does a flow end?
├─ Success (user completed task)
│ └─ Delegate method with result
│ coordinator.didCompleteLogin(user: user)
│
├─ Cancellation (user backed out)
│ └─ Delegate method without result
│ coordinator.didCancelLogin()
│
└─ Automatic (flow naturally ends)
└─ Parent removes child automatically
No explicit completion needed// ❌ Memory leak — child coordinator retained forever
final class ParentCoordinator {
var childCoordinators: [Coordinator] = []
func startLoginFlow() {
let loginCoordinator = LoginCoordinator()
childCoordinators.append(loginCoordinator)
loginCoordinator.start()
// Never removed! Leaks.
}
}
// ✅ Remove child on flow completion
final class ParentCoordinator: LoginCoordinatorDelegate {
var childCoordinators: [Coordinator] = []
func startLoginFlow() {
let loginCoordinator = LoginCoordinator()
loginCoordinator.delegate = self
childCoordinators.append(loginCoordinator)
loginCoordinator.start()
}
func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator) {
childCoordinators.removeAll { $0 === coordinator }
}
}// ❌ Retain cycle — coordinator never deallocates
final class ChildCoordinator {
var parent: ParentCoordinator // Strong reference!
}
// ✅ Weak parent or delegate
final class ChildCoordinator {
weak var delegate: ChildCoordinatorDelegate?
// OR
weak var parent: ParentCoordinator?
}// ❌ Coordinator doing business logic
final class CheckoutCoordinator {
func completeOrder() async {
// Business logic leaked into coordinator!
let total = cart.items.reduce(0) { $0 + $1.price }
let tax = total * 0.08
try await paymentService.charge(total + tax)
}
}
// ✅ Coordinator orchestrates, ViewModel/UseCase handles logic
final class CheckoutCoordinator {
func showCheckout() {
let viewModel = CheckoutViewModel(
cartService: container.cartService,
paymentService: container.paymentService
)
// ViewModel handles business logic
}
}// ❌ View knows about parent coordinator
struct LoginView: View {
let coordinator: LoginCoordinator
var body: some View {
Button("Done") {
coordinator.parent?.childDidFinish(coordinator) // Wrong!
}
}
}
// ✅ View only knows its immediate coordinator
struct LoginView: View {
let coordinator: LoginCoordinator
var body: some View {
Button("Done") {
coordinator.completeLogin() // Coordinator handles delegation
}
}
}// ❌ New coordinator created on every parent rebuild
struct ParentView: View {
var body: some View {
ChildView() // Child creates its own coordinator
}
}
struct ChildView: View {
@StateObject var coordinator = ChildCoordinator() // Wrong!
}
// ✅ Parent creates and owns coordinator
struct ParentView: View {
@StateObject var childCoordinator = ChildCoordinator()
var body: some View {
ChildView(coordinator: childCoordinator)
}
}// ❌ Bypasses coordinator — navigation untracked
struct UserListView: View {
var body: some View {
NavigationLink("User") {
UserDetailView() // Coordinator doesn't know about this!
}
}
}
// ✅ Delegate navigation to coordinator
struct UserListView: View {
@ObservedObject var coordinator: UsersCoordinator
var body: some View {
Button("User") {
coordinator.showUserDetail(userId: "123")
}
}
}@MainActor
protocol Coordinator: ObservableObject {
associatedtype Route: Hashable
var path: NavigationPath { get set }
func start() -> AnyView
func navigate(to route: Route)
func pop()
func popToRoot()
}
extension Coordinator {
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path = NavigationPath()
}
}@MainActor
protocol ParentCoordinatorProtocol: AnyObject {
var childCoordinators: [any Coordinator] { get set }
func addChild(_ coordinator: any Coordinator)
func removeChild(_ coordinator: any Coordinator)
}
extension ParentCoordinatorProtocol {
func addChild(_ coordinator: any Coordinator) {
childCoordinators.append(coordinator)
}
func removeChild(_ coordinator: any Coordinator) {
childCoordinators.removeAll { $0 === coordinator as AnyObject }
}
}
// Tab coordinator managing child coordinators
@MainActor
final class TabCoordinator: ParentCoordinatorProtocol, ObservableObject {
var childCoordinators: [any Coordinator] = []
lazy var homeCoordinator: HomeCoordinator = {
let coordinator = HomeCoordinator()
coordinator.parent = self
addChild(coordinator)
return coordinator
}()
lazy var profileCoordinator: ProfileCoordinator = {
let coordinator = ProfileCoordinator()
coordinator.parent = self
addChild(coordinator)
return coordinator
}()
}protocol LoginCoordinatorDelegate: AnyObject {
func loginCoordinator(_ coordinator: LoginCoordinator, didFinishWith result: LoginResult)
}
enum LoginResult {
case success(User)
case cancelled
}
@MainActor
final class LoginCoordinator: ObservableObject {
weak var delegate: LoginCoordinatorDelegate?
@Published var path = NavigationPath()
enum Route: Hashable {
case credentials
case forgotPassword
case twoFactor(email: String)
}
func completeLogin(user: User) {
delegate?.loginCoordinator(self, didFinishWith: .success(user))
}
func cancel() {
delegate?.loginCoordinator(self, didFinishWith: .cancelled)
}
}@MainActor
final class AppCoordinator: ObservableObject, ParentCoordinatorProtocol {
var childCoordinators: [any Coordinator] = []
@Published var path = NavigationPath()
func handle(deepLink: DeepLink) {
// Reset to known state
popToRoot()
childCoordinators.forEach { removeChild($0) }
// Navigate to deep link destination
switch deepLink {
case .user(let id):
navigate(to: .userList)
navigate(to: .userDetail(userId: id))
case .checkout:
let checkoutCoordinator = CheckoutCoordinator()
checkoutCoordinator.delegate = self
addChild(checkoutCoordinator)
// Present checkout flow
case .settings(let section):
navigate(to: .settings)
if let section = section {
navigate(to: .settingsSection(section))
}
}
}
}| Scenario | Use Coordinator? |
|---|---|
| Simple 3-5 screen app | No — simple Router |
| Multiple independent flows | Yes |
| Deep linking required | Likely yes |
| Multi-step wizard flows | Yes |
| Cross-tab navigation | Yes |
| A/B testing navigation | Yes |
| Team-based feature ownership | Yes |
| Smell | Problem | Fix |
|---|---|---|
| childCoordinators grows forever | Memory leak | Remove on completion |
| Strong parent reference | Retain cycle | Use weak or delegate |
| Business logic in coordinator | Wrong layer | Move to ViewModel/UseCase |
| View creates NavigationLink | Bypasses coordinator | Delegate to coordinator |
| @StateObject coordinator in child | Recreated on rebuild | Parent owns coordinator |
| Coordinator creates its own views | Can't inject dependencies | Use ViewFactory |
| Aspect | Coordinator | Router |
|---|---|---|
| Complexity | Higher | Lower |
| Hierarchy support | Yes (parent-child) | No |
| Flow isolation | Strong | Weak |
| Testing | Excellent | Good |
| Learning curve | Steep | Gentle |
| Best for | Large apps, teams | Small-medium apps |