iOS開発で頭を悩ませる悩みの1つと言えば画面遷移かと思います。
画面数が少なかったり、画面遷移のフローが単純だった場合は良いのですが、サービスの要件によって、同じ画面でも前の画面に依存して次の遷移先が変わったりということは往往にして起こるように思います。
その場合、通常ViewControllerが前の画面を知っていて、条件分岐をして次の画面に遷移する必要があります。
しかしViewControllerは前の画面を知る必要はあるのでしょうか?
そこで登場したのがApplication Coordinatorという考え方です。
メリットとしては、ViewControllerから画面遷移の興味を引き剥がし、Coordinatorに丸め込むということができることと、一連の画面遷移のフローを個別に分けることができ、ひとまとめに定義することです。
画面遷移の実装
Application Coordinatorの実装は大きく分けて、画面遷移を記述するCoordinatorクラスと、ViewControllerの画面遷移をCoordinator側で注入できるようにTransition Protocolというインターフェースを定義します。
一部の実装や定義は省くため、以下のgithubのソースを参照しながら以下を読んでいただけると幸いです。
https://github.com/takahia1988/ApplicationCoordinatorSample
Transition Protocolの定義
protocol TopTransition {
var onTopButtonTap:(() -> Void)? { get set }
var onBottomButtonTap:(() -> Void)? { get set }
}
その際、ViewController側は、画面遷移の依存をCoordinator側で注入できるようにTransition Protocolに準拠し、画面遷移のTriggerになるEventとの紐付けを行います。
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ロジックを定義していきます。
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に遷移するように実装しています。
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に依存を寄せる方向性を検討しています。