Swift5時代の画面遷移フレームワークを考えるというタイトルで新しい記事を書いています!良ければこちらも参照ください🙇♂️
皆さんはiOSアプリケーションにおいて、画面遷移の実装をどのようにされているでしょうか?
- Storyboard Segueを使う
- 自分で
pushViewController
やpresentViewController
を行う
私はどちらの方法が優れているということはなく、ケースバイケースだと思っています。
ここではその議論ではなく、
- 画面遷移のロジックをどのように記述するか?
- 遷移先にパラメータを渡す場合、どのように行うか?
について、考えていることを書いてみました。
もし「こんなやり方がある」とか「ぼくがかんがえたさいきょうの...」という方法があればコメントいただけると幸いです。
iOSの画面遷移実装のここがイヤ!
prepareForSegueでごにょごにょする
Storyboard segueで画面遷移を実装する場合、prepareForSegue
で遷移先画面のVCにパラメータを渡す等の処理を記述していると思いますが、遷移先VCの参照を得るために以下のようなロジックを実装されているのではないでしょうか?
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が受け入れ可能なパラメータの型を定義したプロトコルです。
原則として、遷移先となるすべてのビューコントローラがこのプロトコルに準拠します。
protocol DestinationType: class
{
associatedtype Context
var context: Context! { get }
}
extension DestinationType where Self: UIViewController {
var context: Context! {
return ContextStore.shared.context(for: self)
}
}
class ItemDetailViewController: UIViewController, DestinationType {
// この画面は、表示するアイテムIDをパラメータとして受け取る
typealias Context = Int
override func viewDidLoad() {
super.viewDidLoad()
// 画面遷移時に、Routerに預けられたコンテキストを取得
let itemID = self.context
...
}
...
}
画面遷移をアシストするUIStoryboardSegue拡張
UIStoryboardSegue
を拡張し、prepareForSegue
内で行う、遷移先VCへのパラメータ渡し処理などの記述を簡素化します。
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に依らない画面遷移機能を提供します。
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で遷移パラメータを定義しておくとよいでしょう。
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
により、以下のような形で画面遷移を実装することができます。
@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
プロトコルを実装し、フィードバックする値の型を指定します。
extension SearchOptionViewController: MK2Router.FeedbackType {
typealias Feedback = String
}
Unwind SegueがトリガされたときのprepareForSegue
UIStoryboardSegue
拡張のmk2.feedback(ifIdentifierEquals:)
メソッドを用いて、フィードバックしたい値を返します。
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// 入力された検索条件をフィードバック
segue.mk2.feedback(ifIdentifierEquals: "feedback") { (source: SearchOptionViewController) in
return self.keywordTextField.text ?? ""
}
}
帰還先ビューコントローラでの値の受け取り
UIStoryboardSegue
拡張のmk2.feedback(from:)
メソッドを用いて、フィードバックされた値を取得します。
@IBAction func unwindFromSearchOption(_ segue: UIStoryboardSegue) {
// SeachOptionViewControllerからのフィードバックを取得し、再検索する
if let keyword = segue.mk2.feedback(from: SearchOptionViewController.self) {
self.loadItems(keyword: keyword)
}
}
Context同様、フィードバックした値は元のビューコントローラとともに消失します。