iOS
Swift

Application Coordinatorを使ってぐちゃぐちゃになった遷移を綺麗に管理する

More than 1 year has passed since last update.

動機

Storyboard&Segueでの管理は便利ですが複雑なアプリを作っていくとどんどん
辛くなっていきます。例えば以下のようなケース。

  1. ViewController間で値を引き回したい
  2. どこのページにいても指定したページに遷移がしたい(アプリ内通知やURLSchemaでの移動)
  3. 表示されているページに応じて処理を変えたい(初回登録フロー時にはxxxの通知は出さない、など)
  4. 起動時に条件(ログインしているかどうかなど)に応じて遷移を変えたい

1番はhttps://github.com/tokorom/TKRSegueOptions を使ってperformSegueを
呼ぶ場所で値を渡せるようにすれば少し綺麗になるんですが、依然として遷移先の
情報を知ってないと何を渡せばいいかが決まらず、変更に弱い作りになってしまっていました。
2番はNotificationを使って根っこのViewControlerなどが操作するようにしてたんですが、
根っこのViewControllerがどんどん汚くなっていくという事態になりがちでした。

総じて何が問題かというと、SegueとStoryboardによってViewController同士の関心を
不要にしようとしているにもかかわらず、現実には 遷移先はもちろん、今どういう画面状態なのか
というのを管理したくなるケースが多い
せいなんですよね。

辛いなーと思いながらやってたんですが、try!swiftの記事を見ていたら
https://speakerdeck.com/ayanonagon/shi-jian-de-boundaries
でApplicationCoordinatorというパターンが紹介されており、
これだ!と思って当てはめてみたらぴったりハマってすっきりしたのでシェアします。

元の発表ではApplicationCoordinator自体を細切れにして抽象化してたんですが、
OHHTTPStubsやSwiftGenの作者のolivierさん(https://github.com/AliSoftware )が
公開していた
https://gist.github.com/AliSoftware/6d2d146f7baccb0099cc
の方がシンプルに分離していて導入しやすそうだったのでそれを参考にさせてもらいました。

基本方針

  • Segueは使わない。
  • ViewControllerの生成はStoryboardやxibを利用
  • 各ViewControllerに、遷移を定義した**TransitionDelegate protocolを定義して、transitionDelegateとして弱参照する
  • ApplicationCoordinatorがそのprotocolを実装し、Controllerを生成したタイミングで自身を渡す

というものです。元記事では遷移closureを持ったstructを渡すようになっていますが、コメントでも言及されているように循環参照になってしまうことや、そもそも遷移時に動的に遷移アクションを生成したいケースも少ないのでdelegateパターンのほうがいいんじゃないかなと思いました。共通するシンプルな遷移は別protocolにして切り出せばいいですしね。

主要部分を解説していきます。ビルドしてないのでタイポしてたらすいません。

ApplicationCoordinatorの実装

以下のように各種TransitionDelegateを実装したApplicationCoordinatorを実装します。

ApplicationCoordinator.swift
class ApplicationCoordinator: 
    WelcomeViewControllerTransitionDelegate 
    HomeViewControllerTransitionDelegate 
{
    private let window: UIWindow

    init(window: UIWindow) {
        self.window = window
    }

    func start() {

        let initVc: UIViewController = R.storyboard.main.homeViewController()!
        initVc.transitionDelegate = self

        let nvc = UINavigationController(rootViewController: initVc)
        window.rootViewController = nvc
        window.makeKeyAndVisible()
    }
 //以下遷移の定義が続く   
}

そしてStoryboardでの開始を止め、ApplicationDelegateからこれを呼び出します。
Storyboardでの開始をやめるためにはInfo.plistから「Main storyboard file base name」の項目を消します。

AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    lazy var applicationCoordinator: ApplicationCoordinator = {
        return ApplicationCoordinator(window: self.window!)
    }()


    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        //通常ならwindowが既にセットされた状態で入ってくるが自前で生成してやる必要がある
        window = UIWindow(frame: UIScreen.mainScreen().bounds)
        applicationCoordinator.start()
        return true
    }
//以下略
}

TransitionDelegateの定義と呼び出し

次に遷移が発生するViewControllerでTransitionProtocolを定義してやります。
値を渡したいような場合はここで定義してやります。

HomeViewController.swift
protocol HomeViewControllerTransitionDelegate: class {
    func pushProductPage(productId: Int)
}

class HomeViewController: UITableViewController { 
    weak var transitionDelegate: HomeViewControllerTransitionDelegate?

呼び出しはこんな感じにシンプルになります

    //中略  
    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        let product = products[indexPath.row]
        transitionDelegate?.pushProductPage(product.id)
    }
}

Delegateの設定と遷移の定義

最後にApplicationCoordinator側で遷移の内容を定義してやります。ViewControllerは自前で
用意してもいいですし、storyboardやxibからインスタンス化してやってもいいと思います。
https://speakerdeck.com/ayanonagon/shi-jian-de-boundaries
で紹介されているproviderパターンを使うのもいいと思います。

ApplicationCoordinator.swift
    //delegate transition
    func pushProductPage(productId: Int) {
        let nextVc = R.storyboard.main.productViewController()!
        nextVc.productId = productId
        if let currentNavVc = currentNavVc {
            currentNavVc.pushViewController(nextVc, animated: true)
        }
    }

応用

遷移を切り替える

起動時の状態で遷移先を変えたいときはCoordinatorのstart()内で分岐してやるといいです。
もしAPIを叩いた上で遷移先を決める必要があれば、ローディング用のViewControllerを
用意しておき、一度そちらを表示した上で処理が完了したら遷移を続けるといいと思います。

        if APIClient.hasAccessToken() {
            let home = R.storyboard.main.homeViewController()!
            home.transitionDelegate = self
            initVc = home
        } else {
            let welcome = R.storyboard.main.welcomeViewController()!
            welcome.transitionDelegate = self
            initVc = welcome
        }

アプリ内通知やURLSchemaからの呼び出し

アプリ内通知やURLSchemaから特定にページに飛びたい場合、
今どんな画面状況かを呼び出し元が知ることができません。そこでApplicationDelegateに委譲します。ここでは簡単のためNavigationControllerに全てが乗っている想定で書いていますが
presentViewControllerで遷移していたり、TabControllerを使っていたりしたら
適宜戻りたい位置まで戻るコードを書いてください。

    let appDelegate = UIApplication.sharedApplication().delegate as? AppDelegate {
        appDelegate.applicationCoordinator.moveToProductPage(productId)
    }
呼び出し先(ApplicationCoordinator.swift)
    private var currentNc: UINavigationController? {
        return window.rootViewController as? UINavigationController
    }

    private var homeVc: HomeViewController? {
        currentNc?.viewControllers.filter({ (vc: UIViewController) -> Bool in
            return vc is HomeViewController
        }).first
    }
    func moveToProductPage(productId: Int) {
        if let currentNc = currentNavVc, homeVc = homeVc {
            currentNc.popToViewController(homeVc, animated: true)
            pushProductPage(productId)
        }
    }

出ているViewControllerの把握

上記のようにして遷移を全てCoodinatorでやるようにしておけば、何が出ているかを
フラグで管理することができるようになるので後はこれを読んでやるだけです。

ApplicationCoordinator.swift
    let isInAuthFlow: Bool = false
    func start() {
            //~~
        isInAuthFlow = true
        let welcome = R.storyboard.main.homeViewController()!
        welcome.transitionDelegate = self
        initVc = welcome
            //~~
    }

    //call from auth's last page
    func authFlowEnded(){
        isInAuthFlow = false
        //~~

ただフラグ管理は変更時にぬけが出来てバグりやすい上、複雑になるとどんどん汚くなるので、
あまり良い設計じゃないと思います。トップに表示されてるViewControllerを調べて
そのクラスで判別するほうがいいかもしれません。
参考: https://gist.github.com/snikch/3661188

まとめ

まだ全ての書き換えは終わってないですが、黒魔術を使っているわけでもないですし、
全てのsegueを排除する必要がなく共存ができるので導入しやすいんじゃないかなと思っています。
一方でデメリットとしてStoryboard上で遷移の把握ができなくなったり、実装量が増えたりしてしまうので、
アプリの性質を見極めて利用する必要があると思います。