iOS
Swift
ApplicationCoordinator
SwiftDay 23

Getting Started Application Coordinator 〜 ViewControllerに依存しない画面遷移の作り方 〜

iOS開発で頭を悩ませる悩みの1つと言えば画面遷移かと思います。
画面数が少なかったり、画面遷移のフローが単純だった場合は良いのですが、サービスの要件によって、同じ画面でも前の画面に依存して次の遷移先が変わったりということは往往にして起こるように思います。
その場合、通常ViewControllerが前の画面を知っていて、条件分岐をして次の画面に遷移する必要があります。
しかしViewControllerは前の画面を知る必要はあるのでしょうか?
そこで登場したのがApplication Coordinatorという考え方です。
メリットとしては、ViewControllerから画面遷移の興味を引き剥がし、Coordinatorに丸め込むということができることと、一連の画面遷移のフローを個別に分けることができ、ひとまとめに定義することです。

画面遷移の実装

Application Coordinatorの実装は大きく分けて、画面遷移を記述するCoordinatorクラスと、ViewControllerの画面遷移をCoordinator側で注入できるようにTransition Protocolというインターフェースを定義します。
一部の実装や定義は省くため、以下のgithubのソースを参照しながら以下を読んでいただけると幸いです。
https://github.com/takahia1988/ApplicationCoordinatorSample

Transition Protocolの定義

TopTransition.swift
protocol TopTransition {
    var onTopButtonTap:(() -> Void)? { get set }
    var onBottomButtonTap:(() -> Void)? { get set }
}

その際、ViewController側は、画面遷移の依存をCoordinator側で注入できるようにTransition Protocolに準拠し、画面遷移のTriggerになるEventとの紐付けを行います。

TopViewController.swift
class TopViewController: UIViewController, TopTransition {

    //MARK: Transition
    var onTopButtonTap:(() -> Void)?
    var onBottomButtonTap:(() -> Void)?

    //MARK: Property
    @IBOutlet private weak var topButton: UIButton!
    @IBOutlet private weak var bottomButton: UIButton!


    //MARK: Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()

        self.title = "TOP"
        self.view.backgroundColor = UIColor.white
        self.setSubViewData()
    }

    //MARK: private method
    private func setSubViewData() {
        self.topButton.setTitle("ログイン", for: .normal)
        self.topButton.titleLabel?.textColor = UIColor.brown
        self.topButton.addTarget(self,
                                 action: #selector(topButtonAction(sender:)),
                                 for: .touchUpInside)

        self.bottomButton.setTitle("お店リスト", for: .normal)
        self.bottomButton.titleLabel?.textColor = UIColor.brown
        self.bottomButton.addTarget(self,
                                    action: #selector(bottomButtonAction(sender:)),
                                    for: .touchUpInside)

    }

    //MARK: private Action
    @objc private func topButtonAction(sender: UIButton) {
        self.onTopButtonTap?()
    }

    @objc private func bottomButtonAction(sender: UIButton) {
        self.onBottomButtonTap?()
    }
}

Coordinatorの実装

Coordinator側では、実際に画面遷移を定義していきます。
まずはNavigationControllerをWrappingしたRouterロジックを定義していきます。

RouterImpl.swift
class RouterImpl: Router {
    var navigatonController: UINavigationController

    required init(navigatonController: UINavigationController) {
        self.navigatonController = navigatonController
    }

    //MARK: present view
    func present(controller: UIViewController?) {
        self.present(controller: controller,
                     animated: true)
    }

    func present(controller: UIViewController?, animated: Bool) {
        self.present(controller: controller,
                     animated: animated,
                     completion: nil)
    }

    func present(controller: UIViewController?, animated: Bool, completion: (() -> ())?) {
        guard let controller = controller else {
            return
        }

        self.navigatonController.present(controller,
                                         animated: animated,
                                         completion: completion)
    }

    //MARK: push view
    func push(controller: UIViewController?) {
        self.push(controller: controller, animated: true)
    }

    func push(controller: UIViewController?, animated: Bool) {
        guard let controller = controller, controller is UINavigationController == false else {
            assertionFailure("Deprecated push UINavigationController.")
            return
        }

        self.navigatonController.pushViewController(controller,
                                                    animated: animated)
    }

    //MARK: pop view
    func pop() {
        self.pop(animated: true)
    }

    func pop(animated: Bool) {
        self.navigatonController.popViewController(animated: animated)
    }

    func popToStackedController(controller: UIViewController) {
        self.popToStackedController(controller: controller, animated: true)
    }

    func popToStackedController(controller: UIViewController, animated: Bool) {
        let viewControllers = self.navigatonController.viewControllers

        let index = viewControllers.index(of: controller)
        if let theIndex = index {
            let newControllers = viewControllers.enumerated().filter { $0.0 <= theIndex }.map { $0.1 }
            self.setRootControllers(controllers: newControllers, animated: true)
            return
        }
    }

    //MARK: dismiss view
    func dismiss() {
        self.dismiss(animated: true)
    }

    func dismiss(animated: Bool) {
        self.dismiss(animated: animated, completion: nil)
    }

    func dismiss(animated: Bool, completion: (() -> ())?) {
        self.navigatonController.dismiss(animated: animated,
                                         completion: completion)
    }

    //MARK: other transition
    func setRootController(controller: UIViewController?) {
        self.setRootController(controller: controller, animated: false)
    }

    func setRootController(controller: UIViewController?, animated: Bool) {
        guard let controller = controller else {
            return
        }

        self.setRootControllers(controllers: [controller], animated: animated)
    }

    func setRootControllers(controllers: [UIViewController]?) {
        self.setRootControllers(controllers: controllers, animated: false)
    }

    func setRootControllers(controllers: [UIViewController]?, animated: Bool) {
        guard let controllers = controllers else {
            return
        }

        self.navigatonController.setViewControllers(controllers, animated: animated)
    }

    func popToRootController(animated: Bool) {
        let _ = self.navigatonController.popToRootViewController(animated: animated)
    }

    func removeControllerFromStack(viewController: UIViewController?) {
        let viewControllers = self.navigatonController.viewControllers.filter({ (obj) -> Bool in
            return obj != viewController
        })
        self.navigatonController.viewControllers = viewControllers
    }
}

ApplicationCoordinator側では、Transition Protocolの定義に従って画面遷移のロジックを代入していきます。
以下のコードでは、上のボタンを押下された時にLogin画面に、下の画面を押下するとレストラン一覧を表示するFoodListViewControllerに遷移するように実装しています。

ApplicationCoordinator.swift
class ApplicationCoordinator: Coordinator {

    var childCoordinators = [Coordinator]()

    private let window: UIWindow
    private let router: Router
    private let controllersFactory: ControllersFactory
    private let coordinatorFactory: CoordinatorFactory

    init(window: UIWindow,
         router: Router,
         controllersFactory: ControllersFactory,
         coordinatorFactory: CoordinatorFactory) {

        self.window = window
        self.router = router
        self.controllersFactory = controllersFactory
        self.coordinatorFactory = coordinatorFactory
    }

    func start() {
        self.showTopViewController()
    }

    /*
     * Top View Controller
     */
    func showTopViewController() {

        let topTransition = self.controllersFactory.createTopTransition()
        topTransition.onTopButtonTap = { [weak self] in
            self?.showLoginViewCoordinator()
        }

        topTransition.onBottomButtonTap = { [weak self] in
            self?.showFoodListView()
        }

        self.router.setRootController(controller: topTransition.toPresent())
        self.window.rootViewController = self.router.navigatonController
        self.window.makeKeyAndVisible()
    }

    /*
     * Push View Controller
     */
    func showFoodListView() {
        let foodListTransition = self.controllersFactory.createFoodListTransition()
        foodListTransition.onCellTap = { [weak self] (url) in
            self?.showWebView(url: url)
        }
        self.router.push(controller: foodListTransition.toPresent())
    }

    func showWebView(url: URL) {
        let webTransition = self.controllersFactory.createWebTransition(url: url)
        self.router.push(controller: webTransition.toPresent())
    }

    /*
     * Present View Controller By Coordinator
     */
    func showLoginViewCoordinator() {
        let (loginCoordinator, controller) = self.coordinatorFactory.createLoginCoordinator()
        loginCoordinator.finishFlow = { [weak self, weak loginCoordinator] in
            self?.router.dismiss()
            self?.removeChildDependency(coordinator: loginCoordinator)
        }

        loginCoordinator.cancelFlow = { [weak self, weak loginCoordinator] in
            self?.router.dismiss()
            self?.removeChildDependency(coordinator: loginCoordinator)
        }

        self.addChildDependency(coordinator: loginCoordinator)
        loginCoordinator.start()
        self.router.present(controller: controller)
    }

}

Next Step

1年間、Clean ArchitectureとApplication Coordinatorで運用して気づいたことは、今回のようにCoordinatorの依存をViewControllerにした場合、ViewControllerのEventに対して画面遷移する時、Presenterを経由して遷移することが多いため、コードが冗長的になることです。
今後、ApplicationCoordinatorの形は今のままで、VIPERのようにPresenterに依存を寄せる方向性を検討しています。