Coordinator Pattern
Coordinator 패턴은 ViewController가 보유하던 책임 중 Navigation과 관련된 부분을 다른 인스턴스에서 책임지도록 하는 패턴이다.
기존의 ViewController에서 직접 화면 전환 방식
기존의 방식은 ViewController에서 직접적으로 화면 전환을 시행하고, 다음에 띄워질 다른 ViewController에대해 기존 ViewController가 알고있어야 하는 구조이다. 이는 ViewController간에 심한 커플링을 발생시킨다.
이를 해결한 것이 Coordinator 패턴이다.
Coordinator 패턴
모든 ViewController는 Coordinator 인스턴스만 보유할 뿐, 다른 ViewController 인스턴스를 직접적으로 보유하지 않는다. 그저 Coordinator에게 요청할뿐이다.
이런 컨셉을 프로그래밍적으로 구현해내는 모든 것이 Coordinator 패턴이 될 수 있다.
그 중에서도 Navigation Controller를 활용한 가장 기본적인 튜토리얼을 정리한다.
튜토리얼
일단 Coordinator의 역할을 프로토콜을 통해 정의해준다. 일반적으로 아래와 같이 구성된다.
1. 프로토콜 정의
protocol Coordinator{
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get set }
func start()
}
- var childCoordinators: child coordinator를 관리하는 배열
- var navigationController: ViewController들을 push & pop 할 수 있는 Navigation Controller
- func start(): 앱을 관리할 준비가 되었을 때 호출하는 메서드.
2. 프로토콜에 대한 Coordinator 구현체 구현
class MainCoordinator: Coordinator{
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController){
self.navigationController = navigationController
}
func start() {
let vc = OneViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
}
- func start(): 띄워줄 viewController를 생성하고 해당 vc의 coordinator로 자신을 할당한다. 그리고, NavigationController를 활용해 해당 vc를 push한다.
- 여기서 ViewController.instantiate()메서드는 uikit에 포함된 메서드가 아니다. extension으로 뷰 컨트롤러를 생성하고 인스턴스화하는 메서드를 정의한것.
UIViewController+Extension.swift
import UIKit
extension UIViewController {
static func instantiate() -> Self {
let className = String(describing: self)
let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
return storyboard.instantiateViewController(withIdentifier: className) as! Self
}
}
3. 구체적인 타입 설정이 끝났으니 SceneDelegate에서 이를 활용해 rootViewController를 설정한다.
SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coordinator: MainCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let navController = UINavigationController()
coordinator = MainCoordinator(navigationController: navController)
coordinator?.start()
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navController
window?.makeKeyAndVisible()
}
4. ViewController에서 Coordinator 사용
import UIKit
class OneViewController: UIViewController {
var coordinator: MainCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func buttonTap(_ sender: UIButton) {
coordinator?.twoSubscription()
}
}
class MainCoordinator: Coordinator{
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController){
self.navigationController = navigationController
}
func start() {
let vc = OneViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
func twoSubscription(){
let vc = TwoViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
}
OneViewController를 Coordinator의 start()메서드로 띄우고, buttontap(OneViewController의 IBAction)이 호출되면 coordinator의 twoSubscription()메서드를 실행한다. 이렇게 되면 MainCoordinator는 TwoViewController를 인스턴스화해서 push하게된다.
여기까지의 결과
Coordinator의 이점
- 더 이상 view controller들의 flow를 하드코딩하지 않아도 된다.
- view controller간의 확실한 격리 처리가 가능해졌다.
- SOLID원칙중 SRP(단일책임원칙)도 잘 지키게 되었다.
풀리지 않은 문제
- childCoordnator는 언제쓰는걸까?
- TabBarController가 있는 경우에는?
- ViewController간의 데이터 전달은?
하나씩 풀어나가본다.
Child Coordinator
앞서 살펴본 방식처럼 하나의 Coordinator에서 child Coordinator없이 모든 화면을 관리한다면 각 ViewController들은 불필요한 다른 화면 전환 메서드까지 갖게된다.
이는 상당히 비효율적이다.
그래서 사용하는것이 child Coordinator이다. 각 Coordinator는 현재 필요로 하는 현재 필요로 하는 화면 전환 메서드만 보유한다.
class twoCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
weak var parentCoordinator: MainCoordinator?
init(navigationController: UINavigationController){
self.navigationController = navigationController
}
func start() {
let vc = TwoViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
}
- parent Coordinator와 동일한 구조를 가지고 있다.
- 추가적으로 parent Coordinator에 대한 약한 참조를 가지고 있다.
- 외부에서 주입받아 할당되는 navigationController는 parentCoordinator와 동일한 것이다.(부모로부터 주입받는다.)
class MainCoordinator: Coordinator{
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController){
self.navigationController = navigationController
}
func start() {
let vc = OneViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
func twoSubscription(){
let child = twoCoordinator(navigationController: navigationController)
child.parentCoordinator = self
childCoordinators.append(child)
child.start()
}
}
- parent coordicator는 child coordinator를 생성해 parent Coordinator로 자신을 할당한다음 childCoordinators에 추가한다.
- start 메서드를 호출해 화면을 띄워준다.
- 앞서 살표본 방식과는 다르게 화면 전환에서 coordinator만을 사용하고있다.
이제 child Coordinator를 통해 화면 전환을 하는 것까지는 성공했다. 하지만 이대로라면 childCoordinator가 담당하는 화면이 사라진 뒤에도 childCoordinators에 childCoordinator가 남아있을 것이다.
화면 전환 후 삭제
class TwoViewController: UIViewController {
weak var coordinator: twoCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
coordinator?.didFinishTow()
}
}
- 해당 child coordinator를 활용중인 ViewController의 viewDidDisappear에서 coordinator의 didFinishTwo()메서드를 호출한다.
class twoCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
weak var parentCoordinator: MainCoordinator?
init(navigationController: UINavigationController){
self.navigationController = navigationController
}
func start() {
let vc = TwoViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
func didFinishTow(){
parentCoordinator?.childDidFinish(self)
}
}
- didFinishTwo()메서드는 parentCoordinator의 childDidFinish를 호출한다.
class MainCoordinator: Coordinator{
...
func childDidFinish(_ child: Coordinator?) {
for (index, coordinator) in childCoordinators.enumerated() {
if coordinator === child {
print(index)
childCoordinators.remove(at: index)
break
}
}
}
}
- childDidFinish는 Coordinator를 파라미터로 받아 이를 child Coordinator배열에서 삭제하는 메서드이다.
- 다만 화면이 여러개일 경우 viewDidDisappear가 이르게 호출되는 경우도 있기 때문에 주의해서 사용해야한다.
- 여기서 if coordintor === child 부분에서 애를 많이 썼다. === 는 클래스 인스턴스를 비교할 때 "==="연산자를 사용하는 경우 두 인스턴스가 정확히 같은 객체를 가리키는지 확인한다. "===" 비교연산자를 사용할 수 있는 조건은 클래스가 AnyObject프로토콜을 상속하는 경우이다. 그래서 protocol Coordinator: AnyObject를 해주면 가능하다. 사전지식이 모자랐겠지만 예제에 없어서 고생을 많이..했다
Coordinator with Tab Bar Controllers
이 경우 각각의 탭을 각각의 Coordinator가 관리하고 있다고 생각하면 된다.
아래 예시에서는 하나의 탭만 있다고 가정하고 설명한다.
class MainTabbarController: UITabBarController {
let main = MainCoordinator(navigationController: UINavigationController())
override func viewDidLoad() {
super.viewDidLoad()
main.start()
viewControllers = [main.navigationController]
}
- 내부에 coordinator를 들고 이를 직접 넣어준다.
- 물론 AppDelegate나 SceneDelegate에서 window의 rootViewController를 MainTabBarController로 설정해주는 과정이 필요하다.
class MainCoordinator: Coordinator {
...
func start() {
let vc = ViewController.instantiate()
vc.tabBarItem = UITabBarItem(tabBarSystemItem: .favorites, tag: 0)
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
...
}
- start 과정에서 tabBarItem을 설정하는 과정을 하면 끝이다.
viewController 간 데이터 전달
간단하다. 단순히 coordinattor의 메서드에 전달이 필요한 데이터를 입력받고 전달하면 된다.
OneViewController -> TwoViewController의 데이터 전달 과정
class OneViewController: UIViewController {
...
@IBAction func buttonTap(_ sender: UIButton) {
coordinator?.twoSubscription(30)
}
...
}
class MainCoordinator: Coordinator{
...
func twoSubscription(_ number: Int){
let child = twoCoordinator(navigationController: navigationController)
child.parentCoordinator = self
childCoordinators.append(child)
child.number = number
child.start()
}
...
}
class twoCoordinator: Coordinator {
...
var number: Int?
func start() {
let vc = TwoViewController.instantiate()
vc.coordinator = self
vc.number = number
navigationController.pushViewController(vc, animated: true)
}
...
}