[iOS] Coordinator

Emily·2025년 8월 21일
0

CoordinatorNavigation 책임을 갖는 객체다. 코디네이터 패턴을 적용하면 ViewController는 화면 이동(네비게이션) 책임을 덜게 돼 화면 구성(UI)과 이벤트 전달만 담당하면 된다.

  • before : 뷰 컨트롤러에서 직접 네비게이션 구현
class MainViewController: UIViewController {
	// ... //
    
    func addButtonTapped() {
		let viewController = addViewController()
        navigationController?.pushViewController(viewController, animated: true)
    }
}
  • after : 컨트롤러에서 네비게이션 코드 삭제. 코디네이터를 통해 네비게이션 메소드 호출
class MainViewController: UIViewController {
	weak var coordinator: MainListCoordinator?
    
    // ... //
    
    func addButtonTapped() {
		coordinator?.showAddView()
    }
}

Protocol 정의

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 }
    }
}
  1. childCoordinators는 메모리 관리를 위해 필요하다. ViewController에서 weak var로 코디네이터를 약한 참조하기 때문에 네비게이션 메소드의 실행이 끝나면 참조가 풀려 coordinatornil이 된다. 따라서 클래스의 지역변수에 넣어주어 메모리 해제를 방지하는 것이 필요하다.
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이 된다.
        */
    }
}
  1. childDidFinish 메소드는 메모리 해제를 위해 필요하다. 화면으로부터 벗어났을 때 childCoordinators에 추가했던 해당 코디네이터를 삭제해주는 것이다. 다른 방식들도 많지만 나의 경우 프로토콜의 확장을 활용하여 기본구현하였다.
  2. AnyObject : 배열에서 특정 코디네이터를 삭제할 때 클래스 인스턴스 비교 연산자 ===를 사용하기 위해서는 프로토콜에 AnyObject 선언이 필요하다. 이렇게 선언해주면 그 프로토콜이 클래스 전용 프로토콜이 된다. 또한 약한 참조(weak var)를 쓰기 위해서도 필요하다. (===는 참조 비교 연산자로, ==처럼 값 비교가 아닌 메모리 상 같은 객체인지 확인하는 연산자다)

Coordinator 구현

01) initialiser

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 운반 역할도 맡는다. 위의 예시 코드는 모든 코디네이터가 공통으로 갖는 코드의 형태다.

02) start()

start 메소드는 화면으로의 진입을 담당한다. 단순히 push/present 뿐만 아니라 화면에 전달해야하는 인스턴스/데이터를 넘기고 코디네이터를 지정하는 동작도 함께 포함한다.

  • Navigation push
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)
}
  • Modal present
func start() {
	let viewController = EditTaskViewController(repository: repository)
    viewController.coordinator = self
    navigationController.present(viewController, animated: true)
}

03) finish()

finish 메소드는 화면 탈출을 담당한다. pop/dismiss와 메모리 해제(childDidFinish) 동작을 포함한다. 이 때, 상위 뷰의 childCoordinators로부터 스스로를 삭제해야하기 때문에, 하위 뷰의 코디네이터는 parentCoordinator를 참조해야 한다.

  • Navigation pop
final class TodoListCoordinator: Coordinator {
	weak var parentCoordinator: MainListCoordinator?	// TodoList 뷰의 상위 뷰인 MainList 뷰 코디네이터
    
    // ... //
    
    func finish() {
		navigationController.popViewController(animated: true)
        parentCoordinator?.childDidFinish(self)
    }
}
  • Modal dismiss : 모달 뷰의 경우 completion 클로저에서 childDidFinish를 호출해준다.
func finish() {
	navigationController.dismiss(animated: true) { [weak self] in
		self?.parentCoordinator?.childDidFinish(self)
	}
}

04) show()

한 화면에서 다른 화면으로 이동할 때 호출하는 메소드. 하위 뷰의 코디네이터를 선언하고, 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)
}

SceneDelegate에서 호출

앱의 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
    }
}

View Controller에서 호출

01) show

버튼 또는 셀을 탭했을 때 다음 화면으로 넘어가는 코드는 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)
    }
}

02) finish

finish 처리를 할 때 생각해야 하는 부분이 있다. Done 버튼 또는 커스텀 Back 버튼과 같이 코드로 만든 트리거를 눌러 화면을 탈출하는 경우 명시적으로 coordinator?.finish()를 호출할 수 있지만 커스텀 하지 않아도 알아서 생성되는 네비게이션 Back 버튼을 탭하거나 Modal 화면을 아래로 끌어내릴 경우에는 개발자가 pop/dismiss를 명시적으로 호출하지 않아 finish도 호출할 일이 없다는 것이다. 따라서 따로 처리가 필요하다.

화면이 사라지고 나서 호출되는 viewDidDisappear 시점을 활용하여 childDidFinish가 호출되도록 구현했다.

  • Navigation pop : 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)
        }
    }
}
  • Modal dismiss : 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 동작도 호출되도록 한다
    }
}

패턴을 공부할수록 책임 분리에 대해 조금씩 더 알아가는 기분이다.

profile
iOS Junior Developer

0개의 댓글