Swift時代の画面遷移実装を考える (Swift3改訂版)


> [Swift5時代の画面遷移フレームワークを考える](https://qiita.com/imk2o/items/526fa597d98274efc4b1)というタイトルで新しい記事を書いています！良ければこちらも参照ください🙇‍♂️

皆さんはiOSアプリケーションにおいて、画面遷移の実装をどのようにされているでしょうか？

* Storyboard Segueを使う
* 自分で`pushViewController`や`presentViewController`を行う

私はどちらの方法が優れているということはなく、ケースバイケースだと思っています。
ここではその議論ではなく、

* 画面遷移のロジックをどのように記述するか？
* 遷移先にパラメータを渡す場合、どのように行うか？

について、考えていることを書いてみました。
もし「こんなやり方がある」とか「ぼくがかんがえたさいきょうの...」という方法があればコメントいただけると幸いです。

## iOSの画面遷移実装のここがイヤ！

### prepareForSegueでごにょごにょする

Storyboard segueで画面遷移を実装する場合、`prepareForSegue`で遷移先画面のVCにパラメータを渡す等の処理を記述していると思いますが、遷移先VCの参照を得るために以下のようなロジックを実装されているのではないでしょうか？

```swift
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

        switch segue.identifier {
        case "showDetail"?:
            guard
                let indexPath = self.tableView.indexPathForSelectedRow,
                let selectedItem = self.items?[indexPath.row],
                let detailViewController = segue.desitination as? DetailViewController
            else {
                fatalError()
            }

            // 遷移先VCに選択されたアイテムのIDを渡す
            destinationViewController.itemID = selectedItem.ID

        case "preferences"?:
            // 設定画面はナビゲーションコントローラ配下にある
            guard
               let navigationController = segue.destination as? UINavigationController,
               let preferencesViewController = navigationController.topViewController as? PreferencesViewController
             else {
                 fatalError()
             }

             // 遷移先VCにパラメータを渡すなど...
             preferencesViewController.xxx = ...

        default:
            break // FIXME: Unknown segue
        }
    }
```

ダウンキャストを評価したり、Navigation Controllerの有無を考慮したり、あまり書きたくないコードですね...

### 遷移先へのパラメータの渡し方が原始的

上記の例のとおり、遷移先にパラメータを渡す方法が原始的で、これもどうにかならないかなぁといつも感じるポイントです。

### 見えない導線

自分は導線を明確にするという意味で、できるかぎりStoryboard segueで画面遷移を表現したいと思っていますが、そうできない場合があります。

* プッシュ通知をユーザがタップしたら、その内容に対応する画面に遷移したい
* URLスキームでアプリが起動された場合、はじめに対応する画面を表示したい
* あらゆる画面から割り込み遷移する可能性がある画面、例えばオンデマンドでログインを要求する場合

このようなときは`presentViewController`などを使うことになりますが、ここでも上記の「パラメータの渡し方」が気になるのと、こういった遷移が必要な画面の生成の手続きを共通化したいと考えるでしょう。

## Swiftの言語特性を生かしたRouterの実装

このような課題を解決すべく、Swiftの言語特性を生かした`Router`フレームワークを実装してみました。サンプルコードを公開しています。
https://github.com/imk2o/MK2Router

### DestinationType: 遷移先が受け入れるパラメータを制約する

`DestinationType`は、遷移先VCが受け入れ可能なパラメータの型を定義したプロトコルです。
原則として、遷移先となるすべてのビューコントローラがこのプロトコルに準拠します。

```swift:DestinationType.swift
protocol DestinationType: class
{
    associatedtype Context
    var context: Context! { get }
}

extension DestinationType where Self: UIViewController {
    var context: Context! {
        return ContextStore.shared.context(for: self)
    }
}
```

```swift:ItemDetailViewController.swift
class ItemDetailViewController: UIViewController, DestinationType {
    // この画面は、表示するアイテムIDをパラメータとして受け取る
    typealias Context = Int

    override func viewDidLoad() {
        super.viewDidLoad()

        // 画面遷移時に、Routerに預けられたコンテキストを取得
        let itemID = self.context
        ...
    }

    ...
}
```

### 画面遷移をアシストするUIStoryboardSegue拡張

`UIStoryboardSegue`を拡張し、`prepareForSegue`内で行う、遷移先VCへのパラメータ渡し処理などの記述を簡素化します。

```swift:ItemListViewController.swift
    override func prepare(for segue: UIStoryboardSegue, sender: AnyObject?) {
        // segue.identifierが"showDetail"の場合、
        // 遷移先のItemDetailViewControllerにInt型のパラメータを渡す
        segue.mk2.context(ifIdentifierEquals: "showDetail") { (destination: ItemDetailViewController) -> Int in
            guard
                let indexPath = self.tableView.indexPathForSelectedRow,
                let selectedItem = self.items?[indexPath.row]
            else {
                fatalError()
            }
        
            return selectedItem.ID
        }
    }
```

### Router: 汎用の画面遷移

`Router`はStoryboard segueに依らない画面遷移機能を提供します。

```swift:Router.swift
class Router {
    static let shared: Router = Router()
    
    private init() {
    }

    /**
     画面遷移を行う.
     遷移元のビューコントローラがNavigation Controller配下にあり、遷移先がNavigation Controllerでなければ
     プッシュ遷移、それ以外の場合はモーダル遷移を行う.
     
     - parameter sourceViewController:      遷移元ビューコントローラ.
     - parameter destinationViewController: 遷移先ビューコントローラ.
     - parameter contextForDestination:     遷移先へ渡すコンテキストを求めるブロック.
     */
    func perform<DestinationVC>(
        _ sourceViewController: UIViewController,
        destinationViewController: UIViewController,
        contextForDestination: ((DestinationVC) -> DestinationVC.Context)
    ) where DestinationVC: DestinationType, DestinationVC: UIViewController

    /**
     画面遷移を行う.
     
     - parameter sourceViewController:  遷移元ビューコントローラ.
     - parameter storyboardName:        遷移先ストーリーボード名.
     - parameter storyboardID:          遷移先ストーリーボードID. nilの場合はInitial View Controllerが遷移先となる.
     - parameter contextForDestination: 遷移先へ渡すコンテキストを求めるブロック.
     */
    func perform<DestinationVC>(
        _ sourceViewController: UIViewController,
        storyboardName: String,
        storyboardID: String? = nil,
        contextForDestination: ((DestinationVC) -> DestinationVC.Context)
    ) where DestinationVC: DestinationType, DestinationVC: UIViewController
```

この実装はアプリに依存しない汎用機能なのでそのままは使いづらいでしょう。
次に、各アプリにおける最適化の方法を紹介します。

### 各アプリケーションに最適化する

対象アプリにおいて必要となる「見えない導線」に対応するルートを`enum`で定義し、それぞれ`case`で画面遷移を行う`perform`メソッドを実装します。
このとき、Associated Valueで遷移パラメータを定義しておくとよいでしょう。

```swift:Routes.swift
enum Route {
    case contactForm(Int)		// 問い合わせ画面(アイテムID)
    case modalItemDetail(Int)	// 詳細画面(アイテムID)
}

extension Route {
    func perform(_ sourceViewController: UIViewController) {
        switch self {
        case .contactForm(let itemID):
            Router.shared.perform(sourceViewController, storyboardName: "Misc", storyboardID: "ContactFormNav") { (destination: ContactFormViewController) -> Int in
                return itemID
            }
        case .modalItemDetail(let itemID):
            Router.shared.perform(sourceViewController, storyboardName: "Main", storyboardID: "ItemDetailNav") { (destination: ItemDetailViewController) -> Int in
                return itemID
            }
        }
    }
}
```

この`Route`により、以下のような形で画面遷移を実装することができます。

```swift:ItemDetailViewController.swift
    @IBAction func showContactForm(sender: UIButton) {
        guard let itemID = self.context else {
            return
        }

        // 指定のルートで問い合わせ画面へ
        Route.contactForm(itemID).perform(self)
    }
```

## Unwind Segueを利用した値のフィードバック

ある画面で入力された値を帰還先の画面にフィードバックしたいことがあります。
Delegateプロトコルを定義してフィードバックさせることも可能ですが、画面間の依存度が高まるのと、複数のステップで入力する場合などに難しくなってくると思います。
そこで、`Unwind Segue`を利用する方法を紹介したいと思います。

### 準備

帰還先のビューコントローラにUnwind Segue用のアクション`unwindFromSearchOption`を定義しておきます。
次にInterface Builderで`Unwind Segue`を作成してください。
`identifier`は"feedback"とし、`unwindFromSearchOption`アクションにバインドします。

### FeedbackTypeへの準拠

`FeedbackType`プロトコルを実装し、フィードバックする値の型を指定します。

```swift:SearchOptionViewController.swift
extension SearchOptionViewController: MK2Router.FeedbackType {
    typealias Feedback = String
}
```

### Unwind Segueがトリガされたときの`prepareForSegue`

`UIStoryboardSegue`拡張の`mk2.feedback(ifIdentifierEquals:)`メソッドを用いて、フィードバックしたい値を返します。

```swift:SearchOptionViewController.swift
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // 入力された検索条件をフィードバック
        segue.mk2.feedback(ifIdentifierEquals: "feedback") { (source: SearchOptionViewController) in
            return self.keywordTextField.text ?? ""
        }
    }
```

### 帰還先ビューコントローラでの値の受け取り

`UIStoryboardSegue`拡張の`mk2.feedback(from:)`メソッドを用いて、フィードバックされた値を取得します。

```swift:ItemListViewController.swift
    @IBAction func unwindFromSearchOption(_ segue: UIStoryboardSegue) {
        // SeachOptionViewControllerからのフィードバックを取得し、再検索する
        if let keyword = segue.mk2.feedback(from: SearchOptionViewController.self) {
            self.loadItems(keyword: keyword)
        }
    }
```

> Context同様、フィードバックした値は元のビューコントローラとともに消失します。