Coordinator (координатор) — архітектурний патерн навігації, який виносить логіку переходів між екранами з ViewController в окремі класи. Патерн запропонований Soroush Khanlou в 2015 році й отримав широке поширення в iOS-спільноті. Coordinator керує flow застосунку: створює та відображає ViewController, передає дані між екранами й обробляє завершення flow. Патерн вирішує проблему Massive View Controller, виносячи навігацію з контролера. Докладніше — у оригінальній статті про Coordinator.
Головне
Coordinator — патерн, який бере на себе відповідальність за навігацію в iOS-застосунку. У стандартному UIKit ViewController сам керує переходами: present, push, show segue — всі методи навігації викликаються з UIViewController. Coordinator виносить цю логіку: ViewController повідомляє про подію (наприклад, «користувач натиснув кнопку логіна»), Coordinator вирішує, який екран показати далі. ViewController залишається тільки з UI-логікою й делегує навігацію координатору.
Структура патерну — CoordinatorProtocol з методами start() і finish(). start() — початок flow: створення першого ViewController та відображення. finish() — завершення flow з повідомленням батьківського координатора. Router — обгортка над UINavigationController (або UISplitViewController), що надає методи show, push, pop, dismiss. Координатор не працює з UINavigationController напряму — тільки через Router. Це дозволяє тестувати навігацію та перемикати UI-фреймворк.
| Компонент | Роль | Приклад |
|---|---|---|
| Coordinator | Управління flow навігації | AuthCoordinator, ProfileCoordinator |
| Router | Абстракція над UINavigationController | push, present, pop, dismiss |
| ViewController | UI + делегування подій Coordinator | LoginViewController.delegate |
Проблеми, які вирішує Coordinator — Massive View Controller (навігація — часта причина розростання контролера). У стандартному UIKit ViewController містить prepareForSegue, делегати навігації, обробку unwind segues. Coordinator усуває це. Segue в storyboard — статичний зв'язок між екранами, Coordinator дає динамічну навігацію з умовами. Тестування навігації стає можливим: можна протестувати Coordinator без UI, перевіривши послідовність викликів Router.
Базовий Coordinator на Swift — протокол з асоційованим типом для Router і методами start/finish. Router — протокол, що абстрагує UINavigationController. Конкретна реалізація Router обгортає UINavigationController і делегує йому методи. Coordinator приймає Router в init і використовує його для навігації. Дочірні координатори зберігаються в масиві childCoordinators для управління життєвим циклом.
// Router — абстракція навігації
protocol RouterProtocol: AnyObject {
func push(_ viewController: UIViewController, animated: Bool)
func pop(animated: Bool)
func present(_ viewController: UIViewController, animated: Bool)
func dismiss(animated: Bool)
}
final class NavigationRouter: RouterProtocol {
private let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func push(_ vc: UIViewController, animated: Bool) {
navigationController.pushViewController(vc, animated: animated)
}
func pop(animated: Bool) {
navigationController.popViewController(animated: animated)
}
func present(_ vc: UIViewController, animated: Bool) {
navigationController.present(vc, animated: animated)
}
func dismiss(animated: Bool) {
navigationController.dismiss(animated: animated)
}
}
// Coordinator — управління flow
protocol CoordinatorProtocol: AnyObject {
var childCoordinators: [CoordinatorProtocol] { get set }
var router: RouterProtocol { get }
func start()
func finish()
}
class AuthCoordinator: CoordinatorProtocol {
var childCoordinators: [CoordinatorProtocol] = []
let router: RouterProtocol
init(router: RouterProtocol) {
self.router = router
}
func start() {
let loginVC = LoginViewController()
loginVC.onLogin = { [weak self] in
self?.showHome()
}
router.push(loginVC, animated: true)
}
private func showHome() {
let homeCoordinator = HomeCoordinator(router: router)
childCoordinators.append(homeCoordinator)
homeCoordinator.start()
}
func finish() {
childCoordinators.removeAll()
router.pop(animated: true)
}
}
Створення Coordinator в AppDelegate/SceneDelegate — AppDelegate або SceneDelegate створює UINavigationController, обгортає його в NavigationRouter, створює кореневий Coordinator (AppCoordinator) і викликає start(). AppCoordinator вирішує, показати онбординг, логін або головний екран — залежно від стану застосунку. Coordinator — єдина точка входу для навігації, ViewController не знає про інші екрани.
Ієрархія координаторів — AppCoordinator → AuthCoordinator/MainCoordinator → ProfileCoordinator/SettingsCoordinator. Дочірній координатор створюється батьком і зберігається в масиві childCoordinators. Коли дочірній координатор завершує роботу, він викликає finish() у батька, і батько видаляє його з childCoordinators. Це запобігає витокам пам'яті: Coordinator має сильне посилання на ViewController (через Router), і без видалення з childCoordinators об'єкт не звільниться.
// Делегат для зв'язку Coordinator -> Parent
protocol AuthCoordinatorDelegate: AnyObject {
func authCoordinatorFinished(_ coordinator: AuthCoordinator)
}
class AuthCoordinator: CoordinatorProtocol {
weak var delegate: AuthCoordinatorDelegate?
func finish() {
delegate?.authCoordinatorFinished(self)
}
}
// AppCoordinator — батько
class AppCoordinator: AuthCoordinatorDelegate {
func startAuthFlow() {
let authCoordinator = AuthCoordinator(router: router)
authCoordinator.delegate = self
childCoordinators.append(authCoordinator)
authCoordinator.start()
}
func authCoordinatorFinished(_ coordinator: AuthCoordinator) {
childCoordinators.removeAll { $0 is AuthCoordinator }
startMainFlow()
}
}
Управління childCoordinators — видалення Coordinator з масиву — єдиний спосіб його звільнити. Якщо забути видалити завершений Coordinator, він залишається в пам'яті разом із ViewController'ами. Рекомендується: didMove(toParent:) батька, callback при завершенні або Combine publisher для автоматичного видалення. Патерн Coordinator не специфікує механізм повідомлення — делегат, closure або Combine — вибір за розробником.
Передача даних через делегат — дочірній Coordinator визначає протокол делегата з методами, через які передаються результати: func authCoordinator(_:didLoginWith user: User). Батько реалізує протокол і отримує дані при завершенні дочірнього flow. Це типобезпечно та явно. Недолік: для кожного дочірнього Coordinator потрібно писати окремий протокол. Для проєктів із 10+ Coordinator'ами це призводить до збільшення файлів.
Передача даних через тип Result — метод finish приймає Result
| Метод | Плюси | Мінуси |
|---|---|---|
| Delegate | Типобезпечно, явно, окремі протоколи | Багато протоколів, багато бойлерплейта |
| Closure | Компактно, менше файлів | Важко відлагоджувати retain cycle |
| Combine/Rx | Реактивний, легко комбінувати | Залежність від бібліотеки, складніше Debug |
Shared data layer — Coordinator'и не передають дані напряму, а використовують спільний сервіс/репозиторій. AuthCoordinator зберігає токен в Keychain/UserDefaults, ProfileCoordinator читає звідти. Coordinator'и спілкуються через спільний стан (Dependency Injection container), а не через прямі виклики. Це знижує зв'язність Coordinator'ів, але створює неявні залежності від спільного стану.
Coordinator vs Router — Router — компонент Coordinator, що абстрагує UINavigationController. Coordinator відповідає за flow (який екран показати), Router — за механіку (як показати: push/present). Router — це «як», Coordinator — «що». Можна використовувати Router без Coordinator (наприклад, Navigator-синглтон), але Coordinator без Router — просто ViewController з іншою абстракцією. Зазвичай обидва патерни використовуються разом.
Coordinator vs VIPER — VIPER має компонент Wireframe, що відповідає за навігацію — аналог Coordinator. У VIPER Wireframe — частина модуля, Coordinator — окремий шар над модулями. VIPER-модуль (View-Interactor-Presenter-Entity-Router) включає навігацію як частину модуля. Coordinator — зовнішній по відношенню до модулів: він створює та зв'язує модулі, але не входить до їх складу. Coordinator більш гнучкий для перевикористання екранів у різних flow.
MVVM-C — розширення MVVM Coordinator'ом. ViewModel не знає про Coordinator напряму — ViewController делегує навігацію через ViewModel, ViewModel викликає coordinator через протокол. MVVM-C — стандартний підхід для iOS-проєктів із SwiftUI: Coordinator керує NavigationStack або fullScreenCover, ViewModel викликає coordinator публікацією стану. Apple не рекомендує Coordinator для SwiftUI — NavigationStack і NavigationPath вбудовані механізми навігації.
// MVVM-C: ViewModel викликає Coordinator через протокол
protocol AuthNavigationProtocol: AnyObject {
func showMainScreen()
func showForgotPassword()
}
class AuthViewModel: ObservableObject {
weak var navigation: AuthNavigationProtocol?
func loginTapped() {
// логіка...
navigation?.showMainScreen()
}
}
Часті запитання
Для SwiftUI вбудована навігація (NavigationStack, NavigationPath) часто замінює Coordinator. Apple рекомендує path-based navigation. Coordinator має сенс для складних flow із глибокими умовами (онбординг-логін-головний екран залежно від ролі). Для простих застосунків на SwiftUI Coordinator надлишковий — використовуйте NavigationPath.
Ні, це різні патерни. Coordinator керує flow навігації: вирішує, який екран показати, створює ViewController та зв'язує їх. Router — абстракція над UINavigationController: push, present, pop, dismiss. Coordinator використовує Router для виконання навігації. У деяких реалізаціях Router включає логіку Coordinator (Router-per-screen), але це відхилення від оригінального патерну.
Два основні місця витоків: childCoordinators (батько тримає дочірнього, забувши видалити) і Router (UINavigationController тримає ViewController). Рішення: завжди видаляти дочірній Coordinator з масиву при finish(). Використовувати weak посилання для delegate. Для Router — не тримати сильне посилання на UINavigationController, якщо він уже в ієрархії window. Тестуйте deinit Coordinator'а.
Для застосунків із 3-5 екранами Coordinator надлишковий — segue або простий navigationController.pushViewController простіше. Для SwiftUI-застосунків із NavigationStack — теж надлишковий. Coordinator виправданий для застосунків із 15+ екранами, складними flow (онбординг з розгалуженнями, авторизація з відновленням пароля) та змішаними UIKit/SwiftUI проєктами.
Mock Router — перевірка, які методи викликаються та з якими параметрами. Перевірка childCoordinators: після start() масив не порожній, після finish() — порожній. Coordinator тестується без UI: Router — протокол, його mock не потребує UIKit. Використовуйте XCTestExpectation для async flow. В Android — аналогічне тестування NavigationController та NavHost з mock-навігацією.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.