iOS 앱의 구조를 개선하고, 뷰 컨트롤러 간의 결합도를 낮추며, 코드의 재사용성과 테스트 용이성을 높이는데 도움을 줄 수 있는 패턴이다.
앱의 네비게이션 흐름을 관리하는 객체이다. 새로운 뷰 컨트롤러를 표시하거나, 다른 코디네이터를 시작하는 역할을 한다.
사용자 인터페이스를 담당하며, 사용자의 액션에 반응한다. 뷰 컨트롤러는 코디네이터에게 이벤트를 전달하여 네비게이션을 요청할 수 있다.
(선택적) 뷰 컨트롤러의 로직을 분리하여 담당하는 객체이다. 뷰모델은 뷰 컨트롤러의 상태를 관리하고, 뷰 컨트롤러에게 UI 업데이트를 알려준다.
AppCoordinator
는 LoginCoordinator
를 시작할 수 있다. 뷰 컨트롤러가 네비게이션 로직에서 분리되어 있어, 다른 곳에서 쉽게 재사용할 수 있다.
네비게이션 로직이 분리되어 있어, 뷰 컨트롤러와 코디네이터를 별도로 테스트하기 쉽다.
코디네이터를 통해 뷰 컨트롤러에 필요한 의존성을 주입할 수 있어, 느슨한 결합과 높은 응집력을 유지할 수 있다.
뷰 컨트롤러는 UI와 사용자 상호 작용에만 집중하고, 네비게이션 로직은 코디네이터에 의해 처리된다.
- 코디네이터 패턴을 도입하면 앱의 구조가 복잡해질 수 있으며, 새로운 개발자가 코드베이스를 이해하기 어려워질 수 있다.
- 코디네이터, 뷰 컨트롤러, 뷰 모델 등 여러 객체들을 관리해야 하므로, 코드베이스가 커질수록 관리 포인트가 증가한다.
- 간단한 앱의 코디네이터 패턴을 도입하면 오히려 개발 속도가 느려지고, 코드가 불필요하게 복잡해질 수 있다.
import UIKit
// MARK: Coordinator
protocol Coordinator: AnyObject {
// 자신을 완료했다고 부모 코디네이터에게 알리기 위한 델리게이트
var finishDelegate: CoordinatorFinishDelegate? { get set }
// 각 코디네이터에게 할당된 하나의 navigation controller
var navigationController: UINavigationController { get set }
// 모든 자식 코디네이터를 추적하기 위한 배열, 대부분의 경우 이 배열은 하나의 자식 코디네이터만 포함
var childCoordinators: [Coordinator] { get set }
var type: CoordinatorType { get }
// 플로우를 시작하기 위한 로직을 넣는 곳
func start()
// 플로우를 마치기 위한 로직을 넣는 곳, 모든 자식 코디네이터를 정리하고, 자신이 deallocate 될 준비가 되었다는 것을 부모에게 알리는 곳
func finish()
init(_ navigationController: UINavigationController)
}
extension Coordinator {
func finish() {
// 모든 자식 코디네이터를 제거
childCoordinators.removeAll()
// 부모 코디네이터에게 자신이 완료되었음을 알림
finishDelegate?.coordinatorDidFinish(childCoordinator: self)
}
}
// 자식 코디네이터가 완료되어 제거될 준비가 되었음을 부모 코디네이터에게 알리기 위한 델리게이트 프로토콜
protocol CoordinatorFinishDelegate {
func coordinatorDidFinish(childCoordinator: Coordinator)
}
// 플로우 타입 정의
enum CoordinatorType {
case app, login, tab
}
import UIKit
// 해당 coordinator에서 어떤 flow에서 시작할지 결정한다.
protocol AppCoordinatorProtocol: Coordinator {
func showLoginFlow()
func showMainFlow()
}
// AppCoordinator는 App life cycle동안 단 하나만 존재한다.
final class AppCoordinator: AppCoordinatorProtocol {
// App Coordinator는 최상위 Coordinator이므로 finishDelegate는 nil
weak var finishDelegate: CoordinatorFinishDelegate? = nil
var navigationController: UINavigationController
var childCoordinators = [Coordinator]()
var type: CoordinatorType { .app }
private var isLogin: Bool {
get {
return UserDefaults.standard.bool(forKey: "isLogin")
}
set {
UserDefaults.standard.set(newValue, forKey: "isLogin")
}
}
required init(_ navigationController: UINavigationController) {
self.navigationController = navigationController
navigationController.setNavigationBarHidden(true, animated: true)
}
deinit {
print("AppCoordinator deinit")
}
func start() {
if isLogin {
showMainFlow()
} else {
showLoginFlow()
}
}
func showLoginFlow() {
// LoginFlow 구현
let loginCoordinator = LoginCoordinator(navigationController)
loginCoordinator.finishDelegate = self
loginCoordinator.start()
childCoordinators.append(loginCoordinator)
}
func showMainFlow() {
// MainFlow 구현
let tabCoordinator = TabCoordinator(navigationController)
tabCoordinator.finishDelegate = self
tabCoordinator.start()
childCoordinators.append(tabCoordinator)
}
}
extension AppCoordinator: CoordinatorFinishDelegate {
// 해당 coordinator에서 finish가 호출될 때 동일한 child coordinator를 제거하며, navigationController를 초기화 하고 새롭게 coordinator를 설정한다.
func coordinatorDidFinish(childCoordinator: Coordinator) {
childCoordinators = childCoordinators.filter({ $0.type != childCoordinator.type })
switch childCoordinator.type {
case .tab:
isLogin = false
navigationController.viewControllers.removeAll()
showLoginFlow()
case .login:
isLogin = true
navigationController.viewControllers.removeAll()
showMainFlow()
default:
break
}
}
}
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coordinator: AppCoordinator!
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let navigationController = UINavigationController()
window?.rootViewController = navigationController
coordinator = AppCoordinator(navigationController)
coordinator.start()
window?.makeKeyAndVisible()
}
}
import UIKit
final class LoginViewController: UIViewController {
var didSendEventClosure: ((LoginViewController.Event) -> Void)?
private lazy var loginButton: UIButton = {
let btn = UIButton()
btn.setTitle("로그인", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = .systemBlue
btn.layer.cornerRadius = 8.0
btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
deinit {
print("LoginViewController deinit")
}
private func setupView() {
view.backgroundColor = .white
[
loginButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loginButton.widthAnchor.constraint(equalToConstant: 200),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func didTapLoginButton(_ sender: UIButton) {
didSendEventClosure?(.login)
}
}
extension LoginViewController {
enum Event {
case login
}
}
import UIKit
protocol LoginCoordinatorProtocol: Coordinator {
func showLoginViewController()
}
final class LoginCoordinator: LoginCoordinatorProtocol {
weak var finishDelegate: CoordinatorFinishDelegate?
var navigationController: UINavigationController
var childCoordinators: [Coordinator] = []
var type: CoordinatorType { .login }
required init(_ navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
showLoginViewController()
}
deinit {
print("LoginCoordinator deinit")
}
func showLoginViewController() {
let loginVC = LoginViewController()
loginVC.didSendEventClosure = { [weak self] event in
self?.finish()
}
navigationController.pushViewController(loginVC, animated: true)
}
}
finish fuction
을 호출 한다. import Foundation
enum TabBarPage {
case home
case plus
case myPage
init?(index: Int) {
switch index {
case 0:
self = .home
case 1:
self = .plus
case 2:
self = .myPage
default:
return nil
}
}
func pageTitleValue() -> String {
switch self {
case .home:
return "Home"
case .plus:
return "Plus"
case .myPage:
return "MyPage"
}
}
func pageOrderNumber() -> Int {
switch self {
case .home:
return 0
case .plus:
return 1
case .myPage:
return 2
}
}
}
import UIKit
protocol TabCoordinatorProtocol: Coordinator {
var tabBarController: UITabBarController { get set }
func selectPage(_ page: TabBarPage)
func setSelectedInedx(_ index: Int)
func currentPage() -> TabBarPage?
}
final class TabCoordinator: NSObject, TabCoordinatorProtocol {
weak var finishDelegate: CoordinatorFinishDelegate?
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
var tabBarController: UITabBarController
var type: CoordinatorType { .tab }
required init(_ navigationController: UINavigationController) {
self.navigationController = navigationController
self.tabBarController = UITabBarController()
}
func start() {
let pages: [TabBarPage] = [.home, .plus, .myPage].sorted(by: { $0.pageOrderNumber() < $1.pageOrderNumber() })
let controllers: [UINavigationController] = pages.map({ getTabController($0) })
prepareTabBarController(withTabControllers: controllers)
}
private func getTabController(_ page: TabBarPage) -> UINavigationController {
let navController = UINavigationController()
navController.setNavigationBarHidden(false, animated: false)
navController.tabBarItem = UITabBarItem(title: page.pageTitleValue(), image: nil, tag: page.pageOrderNumber())
switch page {
case .home:
let homeVC = HomeViewController()
homeVC.didSendEventClosure = { [weak self] event in
switch event {
case .home:
self?.selectPage(.plus)
}
}
navController.pushViewController(homeVC, animated: true)
case .plus:
let plusVC = PlusViewController()
plusVC.didSendEventClosure = { [weak self] event in
switch event {
case .plus:
self?.selectPage(.myPage)
}
}
navController.pushViewController(plusVC, animated: true)
case .myPage:
let myPageVC = MyPageViewController()
myPageVC.didSendEventClosure = { [weak self] event in
switch event {
case .myPage:
self?.finish()
}
}
navController.pushViewController(myPageVC, animated: true)
}
return navController
}
private func prepareTabBarController(withTabControllers tabControllers: [UINavigationController]) {
tabBarController.delegate = self
tabBarController.setViewControllers(tabControllers, animated: true)
tabBarController.selectedIndex = TabBarPage.home.pageOrderNumber()
tabBarController.tabBar.isTranslucent = false
navigationController.viewControllers = [tabBarController]
}
func currentPage() -> TabBarPage? {
TabBarPage.init(index: tabBarController.selectedIndex)
}
func selectPage(_ page: TabBarPage) {
tabBarController.selectedIndex = page.pageOrderNumber()
}
func setSelectedInedx(_ index: Int) {
guard let page = TabBarPage.init(index: index) else { return }
tabBarController.selectedIndex = page.pageOrderNumber()
}
}
extension TabCoordinator: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
}
}
import UIKit
final class LoginViewController: UIViewController {
var didSendEventClosure: ((LoginViewController.Event) -> Void)?
private lazy var loginButton: UIButton = {
let btn = UIButton()
btn.setTitle("로그인", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = .systemBlue
btn.layer.cornerRadius = 8.0
btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
deinit {
print("LoginViewController deinit")
}
private func setupView() {
view.backgroundColor = .white
[
loginButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loginButton.widthAnchor.constraint(equalToConstant: 200),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func didTapLoginButton(_ sender: UIButton) {
didSendEventClosure?(.login)
}
}
extension LoginViewController {
enum Event {
case login
}
}
import UIKit
final class HomeViewController: UIViewController {
var didSendEventClosure: ((HomeViewController.Event) -> Void)?
private lazy var loginButton: UIButton = {
let btn = UIButton()
btn.setTitle("Home", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = .systemBlue
btn.layer.cornerRadius = 8.0
btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
deinit {
print("HomePageViewController deinit")
}
private func setupView() {
view.backgroundColor = .white
[
loginButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loginButton.widthAnchor.constraint(equalToConstant: 200),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func didTapLoginButton(_ sender: UIButton) {
didSendEventClosure?(.home)
}
}
extension HomeViewController {
enum Event {
case home
}
}
import UIKit
final class PlusViewController: UIViewController {
var didSendEventClosure: ((PlusViewController.Event) -> Void)?
private lazy var loginButton: UIButton = {
let btn = UIButton()
btn.setTitle("Push", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = .systemBlue
btn.layer.cornerRadius = 8.0
btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
deinit {
print("PlusViewController deinit")
}
private func setupView() {
view.backgroundColor = .white
[
loginButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loginButton.widthAnchor.constraint(equalToConstant: 200),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func didTapLoginButton(_ sender: UIButton) {
didSendEventClosure?(.plus)
}
}
extension PlusViewController {
enum Event {
case plus
}
}
import UIKit
final class MyPageViewController: UIViewController {
var didSendEventClosure: ((MyPageViewController.Event) -> Void)?
private lazy var loginButton: UIButton = {
let btn = UIButton()
btn.setTitle("MyPage", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = .systemBlue
btn.layer.cornerRadius = 8.0
btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
deinit {
print("MyPageViewController deinit")
}
private func setupView() {
view.backgroundColor = .white
[
loginButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loginButton.widthAnchor.constraint(equalToConstant: 200),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func didTapLoginButton(_ sender: UIButton) {
didSendEventClosure?(.myPage)
}
}
extension MyPageViewController {
enum Event {
case myPage
}
}
https://github.com/Jeon0976/ToyProject/tree/main/Coordinator/Coordinator
제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.