皆さんこんにちは、iOS Advent Calendar 2019の15日目の @imk2o です。
かなり昔に Swift時代の画面遷移実装を考える という記事を書き、実務でも使っていたのですが、今回は改めてより良い実現方法を模索してみたことを紹介します!
Storyboardとコードとの「距離」を縮める
様々な意見があると思いますが、私はStoryboardやSegueを積極的に使っています。
一方でSwiftコードとの相性については決して良いとは言えません。ただそれを理由に使うのを諦めたくはないので、可能な限りシンプルかつ安全なバインディングを模索しました。
Storyboardの要素とコードは、各要素に付与した Identifier
によって関連付けますが、Segueの場合そのままではリテラルを多用することになりがちです。
そこで下記のように Segue Identifierと同じ名前のプロパティを定義することで、これを画面遷移則および、遷移先のキーとする
ことができるような設計を考案しました。
この方法によって、以下の点が改善されます。
- どんなSegueがあり、どの画面(ViewController)に遷移するのかがひと目でわかる
- Segueの定義と、UIKitの画面遷移ロジックを集約できる
- Segue Identifierを表すリテラルが排除できる
以下は、Item一覧画面からの遷移則を表すコードです。
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詳細画面は以下のような実装になっています。
// MARK: - Routing
extension ItemDetailViewController: DestinationScene {
typealias Context = Int // Item IDをパラメータとして受け取る
}
コードからManual Segueを発火する方法も、上記の遷移則を利用します。
@IBAction func showPageView(_ sender: Any) {
// Segue Routeを使って画面遷移
// (遷移先画面がパラメータ不要なので()を渡している)
self.segueRoutes.showPageView.perform(with: (), from: self)
}
Segue Identifierのリテラルが排除され、遷移先に渡すパラメータの型が明確になったのではないでしょうか。
Global Routing: Segueに依らない画面遷移
ご承知のとおり、Segueのみであらゆる画面遷移を表現できるわけではありません。
Storyboardは使うけどSegueは使わない、と決めて運用しているプロダクトもあるでしょう。
そのような画面はグローバルなルーティング・テーブルに登録し、どの画面からも呼び出せるような仕組みを用意しています。
// 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>()
}
@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自体に注入していないが、意図した振る舞いにはなる)
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 さんです。お楽しみに!