Help us understand the problem. What is going on with this article?

Swift5時代の画面遷移フレームワークを考える

皆さんこんにちは、iOS Advent Calendar 2019の15日目の @imk2o です。

かなり昔に Swift時代の画面遷移実装を考える という記事を書き、実務でも使っていたのですが、今回は改めてより良い実現方法を模索してみたことを紹介します!

Storyboardとコードとの「距離」を縮める

様々な意見があると思いますが、私はStoryboardやSegueを積極的に使っています。
一方でSwiftコードとの相性については決して良いとは言えません。ただそれを理由に使うのを諦めたくはないので、可能な限りシンプルかつ安全なバインディングを模索しました。

Storyboardの要素とコードは、各要素に付与した Identifier によって関連付けますが、Segueの場合そのままではリテラルを多用することになりがちです。
そこで下記のように Segue Identifierと同じ名前のプロパティを定義することで、これを画面遷移則および、遷移先のキーとする ことができるような設計を考案しました。

この方法によって、以下の点が改善されます。

  • どんなSegueがあり、どの画面(ViewController)に遷移するのかがひと目でわかる
  • Segueの定義と、UIKitの画面遷移ロジックを集約できる
  • Segue Identifierを表すリテラルが排除できる

以下は、Item一覧画面からの遷移則を表すコードです。

ItemListViewController.swift
extension ItemListViewController: DeclarativeRouting {
    struct SegueRoutes: SegueRouteDeclarations {
        // Segue Identifierが "showDetail" のAction Segueの遷移則
        private let showDetail = ActionSegueRoute<ItemDetailViewController> { (segue, sender) in
            // 選択したセルに対応するItemを求める
            let source = segue.source as! ItemListViewController
            guard
                let cell = sender as? UITableViewCell,
                let item = source.item(for: cell)
            else {
                fatalError()
            }

            return item.id    // 詳細画面にItem IDを渡す
        }

        // Segue Identifierが "showPageView" のManual Segueの遷移則
        let showPageView = ManualSegueRoute<ItemPageContainerViewController>()

        static let shared: Self = .init()
        private init() {}
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // 代理でハンドリング
        self.segueRoutes.prepare(for: segue, sender: sender)
    }
}

なお遷移先となるItem詳細画面は以下のような実装になっています。

ItemDetailViewController.swift
// MARK: - Routing
extension ItemDetailViewController: DestinationScene {
    typealias Context = Int    // Item IDをパラメータとして受け取る
}

コードからManual Segueを発火する方法も、上記の遷移則を利用します。

ItemListViewController.swift
    @IBAction func showPageView(_ sender: Any) {
        // Segue Routeを使って画面遷移
        // (遷移先画面がパラメータ不要なので()を渡している)
        self.segueRoutes.showPageView.perform(with: (), from: self)
    }

Segue Identifierのリテラルが排除され、遷移先に渡すパラメータの型が明確になったのではないでしょうか。

Global Routing: Segueに依らない画面遷移

ご承知のとおり、Segueのみであらゆる画面遷移を表現できるわけではありません。
Storyboardは使うけどSegueは使わない、と決めて運用しているプロダクトもあるでしょう。
そのような画面はグローバルなルーティング・テーブルに登録し、どの画面からも呼び出せるような仕組みを用意しています。

PreferencesViewController.swift
// MARK: - Routing
extension PreferencesViewController: DescribedDestinationScene {
    // パラメータなし
    typealias Context = Void

    // Preferences.storyboardのInitial View Controllerに対応する
    static let storyboardDescription = StoryboardDescription(name: "Preferences")
}

extension Routes {
    // どこからでも遷移可能なルートとして追加
    static let showPreferences = GlobalRoute<PreferencesViewController>()
}
ItemListViewController.swift
    @IBAction func showPreferences(_ sender: Any) {
        // Global Routeを使って画面遷移
        Routes.showPreferences.perform(with: (), from: self)
    }

原理

SwiftPM化したライブラリサンプルコードをGitHubで公開していますので、詳細はそちらを読んでください🙇‍♂️
ポイントだけ列挙すると、

  • Segue Identifierと SegueRoutes 下のプロパティとのバインディングは Mirror を使っている
  • 遷移先画面は DestinationScene プロトコルに準拠し、受け取る型を宣言している
  • 受け取ったパラメータはVCが保有するのではなく ContextStore にVCのインスタンスと紐付けて管理している
    • NSMapTable でVCを弱参照キーとすることで、パラメータは自動破棄
  • Global Routingできる画面は
    • DescribedDestinationScene プロトコルに準拠し、インスタンス化のためのストーリーボード情報を記述
    • Routes にタイプ・プロパティを追加して、画面VCに一意な名前を付与

Segue Actionの必要性

このフレームワークが出来てしまうと、iOS13から使えるようになったSegue Actionは果たして必要なのか?という疑問に辿り着きました。Segue ActionによってStoryboard/Segueを使いつつDIできる利点はありますが、このフレームワークでも DescribedDestinationScene に準拠すればDIが可能です。
(正確にはVC自体に注入していないが、意図した振る舞いにはなる)

SomeViewController.swift
protocol SomeViewModel { ... }
class SomeViewController: UIViewController, DescribedDestinationScene {
    typealias Context = SomeViewModel
    static let storyboardDescription = StoryboardDescription(...)
    ...
}

// Test with mock
struct MockViewModel: SomeViewModel { ... }
let injectableVC = InjectableViewController.instantiate(with: MockViewModel())
...

実装コストや複雑さの面においても、Segue Actionと大差ないと思います。
Manual Segueを使う場合は結局Segue Identifierで performSegue() する必要があるため、このフレームワークの方がシンプルに扱えるのではないでしょうか。

引き続きSegue Actionの利点を探りつつ、有用であればフレームワークへ統合していきたいと思います。

今後の課題

まだコンセプトレベルのフレームワークであるため、実用度を上げるべく以下の課題について検討し改善したいと思います。

  • Unwind Segue対応
  • guard条件(ログインしていないと遷移できない等のコントロール)
  • Segue Actionの統合検討

明日は @k0mach1 さんです。お楽しみに!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした