Coordinator Pattern
ViewController가 보유한 책임 중 Navigation과 관련된 부분을 다른 인스턴스에서 책임지도록 하는 패턴
기존의 ViewController에서 직접적으로 화면전환을 시행하는 방식은
다음에 띄워질 다른 ViewController에 대해 기존 ViewController가 알고 있어야하는 구조다.
이렇게 하면 ViewController 인스턴스 간에 심한 커플링을 발생시킨다. (커플링: 두 요소간의 상호의존성)
이를 해결한 것이 Coordinator 패턴이다.
모든 VIewController는 Coordinator 인스턴스만 보유할 뿐, 다른 ViewController의 인스턴스를 직접적으로 보유하지 않는다.
그저 Coordinator에 요청할 뿐이다.
이런 컨셉을 프로그래밍적으로 구현해내는 모든 것이 Coordinator 패턴이 될 수 있다.
구현방법
1. Coordinator 프로토콜 설정
먼저 Coordinator의 역할을 프로토콜로 정의해준다.
일반적으로 아래처럼 구성된다. (세부사항은 변경될수도 있음)
protocol Coordinator {
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get set }
func start()
}
- childCoordinators : child coordinator를 관리하는 배열을 보유해야 한다.
- navigationController : viewController들을 push&pop할 수 있는 Navigation Controller를 보유해야 한다.
- start() : 앱을 관리할 준비가 되었을 때 호출한다.
2. 프로토콜에 대한 Coordinator 구현체 구현
이제 이 프로토콜에 대한 구체타입을 구현한다.
class MainCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let vc = ViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
}
- start() :
1. 띄워줄 viewController를 생성한다.
2. 해당 뷰컨의 coordinator로 자신을 할당한다. (3단계에서 뷰컨에 coordinator propertry를 추가할 예정)
3. navigationController를 활용해 해당 뷰컨을 push한다.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
let navController = UINavigationController()
coordinator = MainCoordinator(navigationController: navController)
coordinator?.start()
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = navController
window?.makeKeyAndVisible()
return true
}
- 이제 구체 타입 설정이 끝났으니 AppDelegate(혹은 SceneDelegate)에서도 이를 활용해 rootViewController를 설정해준다.
3. ViewController에서 Coordintator 사용
이제 viewController 쪽에서 Coordinator를 보유하도록 하고 coordinator 내부에 필요한 동작을 정의하면 된다.
class MainCoordinator: Coordinator {
...
func buySubscription() {
let vc = BuyViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
func createAccount() {
let vc = CreateAccountViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
}
class ViewController: UIViewContoller {
var coordinator: Coordinator?
...
@IBAction func buyTapped(_ sender: Any) {
coordinator?.buySubscription()
}
@IBAction func createAccount(_ sender: Any) {
coordinator?.createAccount()
}
...
}
- 특정 동작이 필요할 때 Coordinator에 특정 명령을 보내 처리하도록 한다.
결과
- 더이상 뷰컨들의 flow를 하드 코딩하지 않아도 된다.
- 뷰컨 간의 확실한 격리처리가 가능해졌다.
- 훨씬 DRY한 코드가 작성 가능하며, SOLID원칙 중 SRP(단일 책임 원칙)도 잘 지키게 되었다.
하지만 아래와 같은 의문점이 남아있다.
- childCoordinator는 언제쓰는 건가요?
- TabBarController가 있는 경우에는 어떻게 하나요?
- 뷰컨 간 데이터 전달은 어떻게하나요?
이제 이런 것들을 하나씩 알아보겠다.
Child Coordinator
앞서 살펴본 방법처럼 하나의 Coordinator에서 child Coordinator없이 모든 화면을 관리하면
각 뷰컨들은 불필요한 다른 화면 전환의 메서드까지 갖게 된다.
이는 상당히 비효율적이기 때문에 child Coordinator를 사용한다.
각 Coordinator는 현재 필요로하는 화면전환 메서드만 보유한다.
먼저 childCoordinator가 되어줄 Coordinator를 생성해본다.
child Coordinator
class BuyCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
weak var parentCoordinator: MainCoordinator?
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let vc = BuyViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
}
- parent Coordinator와 동일한 구조를 갖고 있다.
- weak var parentCoordinator : 추가적으로 parent Coordinator에 대한 약한 참조를 갖고 있다.
- 외부에서 주입받아 할당되는 navigationController는 parent Coordinator와 동일하다.
(부모로부터 주입받음)
parent Coordinator
class MainCoordinator: Coordinator {
...
func buySubscription() {
let child = BuyCoordinator(navigationController: navigationController)
child.parentCoordinator = self
childCoordinators.append(child)
child.start()
}
...
}
- child.parentCoordinator = self : child 코디네이터를 생성하고 자신을 parent 코디네이터로 등록한다.
- childCoordinators.append(child) : 자신의 child 코디네이터 배열에 생성한 child를 추가한다.
- 화면 전환에서 coordinator만 사용하고 있다.
화면 전환 후 child Coordinator 제거하기
이제 child Coordinator를 통해 화면 전환하는 것까지는 성공했다.
하지만 이대로라면 child Coordinator가 담당하는 화면이 사라진 뒤에도 childCoordinators에 childCoordinator가 남아있을 것이다.
이것을 처리해본다.
class BuyViewController: UIViewController {
weak var coordinator: BuyCoordinator?
...
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
coordinator?.didFinishBuying()
}
...
}
- 해당 child coordinator를 활용 중인 뷰컨의 viewDidDisappear에서 coordinator의 didFinishBuying() 메서드를 호출한다.
class BuyCoordinator: Coordinator {
weak var parentCoordinator: MainCoordinator?
...
func didFinishBuying() {
parentCoordinator?.childDidFinish(self)
}
...
}
- didFinishBuying 메서드는 parentCoordinator의 childDidFinish를 호출한다.
class MainCoordinator: Coordinator {
...
func childDidFinish(_ child: Coordinator?) {
for (index, coordinator) in childCoordinators.enumerated() {
if coordinator === child {
childCoordinators.remove(at: index)
break
}
}
}
...
}
- childDidFinish(child:) : Coordinator를 파라미터로 받아 childCoordinators 배열에서 삭제해준다.
- 화면이 여러 개일 경우 viewDidDisappear가 이르게 호출되는 경우도 있기 때문에 주의해야 한다.
- cf) UINavigationViewControllerDelegate의 delegate 메서드를 활용하는 방법도 있다.
이는 화면 전환이 발생할 때마다 delegate 메서드로 확인하고 childDidFinish 메서드를 실행시키는 구조다.
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를 MainTabBar Controller로 설정해주는 과정이 필요하다.
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을 설정하는 과정을 하면 끝이다.
View Controller 간 데이터 전달
class MainCoordinator: Coordinator {
...
func buySubscription(_ number: Int) {
let child = BuyCoordinator(navigationController: navigationController)
child.parentCoordinator = self
child.number = number
childCoordinators.append(child)
child.start()
}
...
}
단순하게 coordinator의 메서드에서 전달이 필요한 데이터를 입력받고 전달하면 된다.
Tip
protocol Coordinating {
var coordinator: Coordinator? { get set }
}
- coordinator 프로퍼티를 가지도록 하는 coordinating이라는 프로토콜을 관리하면 좀 더 쉽게 프로퍼티를 할당할 수 있다.
- coordinator를 활용할 뷰컨이 이를 채택하도록하면 좀 더 확실한 관리가 가능해진다.
Ref
https://inuplace.tistory.com/1168
이 글은 개인적인 공부를 위해 작성되었으며, 출처는 위의 게시글임을 밝힙니다.
'🍎 iOS > 디자인패턴' 카테고리의 다른 글
Clean Architecture (0) | 2023.04.28 |
---|---|
델리게이트 패턴 (0) | 2023.03.22 |