Coordinator
는 Navigation
책임을 갖는 객체다. 코디네이터 패턴을 적용하면 ViewController
는 화면 이동(네비게이션) 책임을 덜게 돼 화면 구성(UI)과 이벤트 전달만 담당하면 된다.
class MainViewController: UIViewController {
// ... //
func addButtonTapped() {
let viewController = addViewController()
navigationController?.pushViewController(viewController, animated: true)
}
}
class MainViewController: UIViewController {
weak var coordinator: MainListCoordinator?
// ... //
func addButtonTapped() {
coordinator?.showAddView()
}
}
ViewModel
이나 Presenter
처럼 Coordinator
역시 각 화면마다 전담 객체가 존재하는데, 이때 모든 코디네이터가 지켜야하는 필수사항을 프로토콜로 정의하여 채택시킨다.
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get set }
}
extension Coordinator {
func childDidFinish(_ child: Coordinator?) {
guard let child else { return }
childCoordinators.removeAll { $0 === child }
}
}
childCoordinators
는 메모리 관리를 위해 필요하다. ViewController
에서 weak var
로 코디네이터를 약한 참조하기 때문에 네비게이션 메소드의 실행이 끝나면 참조가 풀려 coordinator
가 nil
이 된다. 따라서 클래스의 지역변수에 넣어주어 메모리 해제를 방지하는 것이 필요하다.class AddViewController: UIViewController {
weak var coordinator: AddListCoordinator? // 약한 참조
func doneButtonTapped() {
coordinator?.finish()
}
}
class AddCoordinator: Coordinator {
// ... //
func start() {
let viewController = AddViewController()
viewController.coordinator = self // 뷰 컨트롤러의 weak var에 코디네이터 스스로를 할당
navigationController.pushViewController(viewController, animated: true)
}
}
class MainCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
func showAddView() {
let addCoordinator = AddCoordinator(...)
childCoordinators.append(addCoordinator) // 배열에 추가해준다.
addCoordinator.start()
/*
childCoordinators.append를 안해줬다면 메소드의 모든 동작 실행이 끝났을 때
addCoordinator가 scope 바깥으로 벗어나기 때문에
viewController.coordinator는 nil이 된다.
*/
}
}
childDidFinish
메소드는 메모리 해제를 위해 필요하다. 화면으로부터 벗어났을 때 childCoordinators
에 추가했던 해당 코디네이터를 삭제해주는 것이다. 다른 방식들도 많지만 나의 경우 프로토콜의 확장을 활용하여 기본구현하였다.AnyObject
: 배열에서 특정 코디네이터를 삭제할 때 클래스 인스턴스 비교 연산자 ===
를 사용하기 위해서는 프로토콜에 AnyObject
선언이 필요하다. 이렇게 선언해주면 그 프로토콜이 클래스 전용 프로토콜이 된다. 또한 약한 참조(weak var
)를 쓰기 위해서도 필요하다. (===
는 참조 비교 연산자로, ==
처럼 값 비교가 아닌 메모리 상 같은 객체인지 확인하는 연산자다)final class AppCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
private let repository: TodoRepository
init(navigationController: UINavigationController, repository: TodoRepository) {
self.navigationController = navigationController
self.repository = repository
}
// ... //
}
내 프로젝트의 경우 SceneDelegate
에서 TodoRepository
를 생성하여 하위 뷰로 이동할 때 전달하여 계속 같은 객체를 사용하도록 구현하고 있기 때문에, 코디네이터가 repository
운반 역할도 맡는다. 위의 예시 코드는 모든 코디네이터가 공통으로 갖는 코드의 형태다.
start
메소드는 화면으로의 진입을 담당한다. 단순히 push/present
뿐만 아니라 화면에 전달해야하는 인스턴스/데이터를 넘기고 코디네이터를 지정하는 동작도 함께 포함한다.
func start() {
let viewController = MainListViewController(repository: repository)
viewController.coordinator = self // 뷰 컨트롤러의 코디네이터에 자신을 할당
navigationController.pushViewController(viewController, animated: true)
}
// 데이터도 함께 넘기는 경우
func start(with list: ListEntity) {
let viewController = TodoListViewController(repository: repository, list: list)
viewController.coordinator = self
navigationController.pushViewController(viewController, animated: true)
}
func start() {
let viewController = EditTaskViewController(repository: repository)
viewController.coordinator = self
navigationController.present(viewController, animated: true)
}
finish
메소드는 화면 탈출을 담당한다. pop/dismiss
와 메모리 해제(childDidFinish
) 동작을 포함한다. 이 때, 상위 뷰의 childCoordinators
로부터 스스로를 삭제해야하기 때문에, 하위 뷰의 코디네이터는 parentCoordinator
를 참조해야 한다.
final class TodoListCoordinator: Coordinator {
weak var parentCoordinator: MainListCoordinator? // TodoList 뷰의 상위 뷰인 MainList 뷰 코디네이터
// ... //
func finish() {
navigationController.popViewController(animated: true)
parentCoordinator?.childDidFinish(self)
}
}
completion
클로저에서 childDidFinish
를 호출해준다.func finish() {
navigationController.dismiss(animated: true) { [weak self] in
self?.parentCoordinator?.childDidFinish(self)
}
}
한 화면에서 다른 화면으로 이동할 때 호출하는 메소드. 하위 뷰의 코디네이터를 선언하고, childCoordinators
에 추가한 뒤 해당 코디네이터의 start()
를 호출한다. 이 때 parentCoordinator
에 자신을 할당한다.
func showTodoListView(with list: ListEntity) {
let todoListCoordinator = TodoListCoordinator(navigationController: navigationController, repository: repository)
todoListCoordinator.parentCoordinator = self
childCoordinators.append(todoListCoordinator)
todoListCoordinator.start(with: list)
}
앱의 root view
로 진입
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 }
let window = UIWindow(windowScene: windowScene)
window.backgroundColor = .systemBackground
// 인스턴스 생성하여 coordinator로 전달
let repository = TodoRepository()
let navigationController = UINavigationController()
// 코디네이터 할당
coordinator = AppCoordinator(navigationController: navigationController, repository: repository)
window.rootViewController = navigationController
window.makeKeyAndVisible()
self.window = window
coordinator?.start() // root view start
}
}
버튼 또는 셀을 탭했을 때 다음 화면으로 넘어가는 코드는 coordinator.showTodoListView(...)
호출이면 된다.
class MainListViewController: UIViewController {
weak var coordinator: MainListCoordinator?
// ... //
// add button이 탭 되면 호출하는 메소드
func presentAddListViewController() {
coordinator?.showAddListView()
}
// 테이블 뷰 셀이 탭 되면 호출하는 메소드 - 셀에 해당하는 list 정보를 전달한다
func pushToTodoListViewController(with list: ListEntity) {
coordinator?.showTodoListView(with: list)
}
}
finish
처리를 할 때 생각해야 하는 부분이 있다. Done
버튼 또는 커스텀 Back
버튼과 같이 코드로 만든 트리거를 눌러 화면을 탈출하는 경우 명시적으로 coordinator?.finish()
를 호출할 수 있지만 커스텀 하지 않아도 알아서 생성되는 네비게이션 Back
버튼을 탭하거나 Modal
화면을 아래로 끌어내릴 경우에는 개발자가 pop/dismiss
를 명시적으로 호출하지 않아 finish
도 호출할 일이 없다는 것이다. 따라서 따로 처리가 필요하다.
화면이 사라지고 나서 호출되는 viewDidDisappear
시점을 활용하여 childDidFinish
가 호출되도록 구현했다.
isMovingFromParent
변수를 활용하여 pop 여부를 확인final class TodoListCoordinator: Coordinator {
// ... //
func finish(shouldPop: Bool) {
if shouldPop {
navigationController.popViewController(animated: true)
}
parentCoordinator?.childDidFinish(self)
}
}
class TodoListViewController: UIViewController {
weak var coordinator: TodoListCoordinator?
// ... //
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent { // navigation pop의 경우 이 변수를 사용해서 확인해야 함
coordinator?.finish(shouldPop: false)
}
}
}
isBeingDismissed
변수를 활용해 dismiss 여부 확인final class EditTaskCoordinator: Coordinator {
// ... //
func finish(shouldDismiss: Bool) {
if shouldDismiss {
navigationController.dismiss(animated: true) { [weak self] in
self?.parentCoordinator?.childDidFinish(self)
}
} else {
parentCoordinator?.childDidFinish(self)
}
}
}
class EditTaskViewController: UIViewController {
weak var coordinator: EditTaskCoordinator?
// ... //
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed { // modal dismiss의 경우 이 변수를 사용해서 확인해야 함
coordinator?.finish(shouldDismiss: false)
}
}
// done 버튼 tap하면 호출하는 메소드
func dismiss() {
coordinator?.finish(shouldDismiss: true) // true를 전달하여 dismiss 동작도 호출되도록 한다
}
}
패턴을 공부할수록 책임 분리에 대해 조금씩 더 알아가는 기분이다.