皆さん画面遷移ってどうされてるんでしょうか?😼
私は何度もpresent
だのpush
だのを書くのがだんだん面倒になってきました
そこでユーティリティを作ってはみたんですが、イマイチと思うところが多いので、記事で晒すことで指摘をもらおうという作戦です
作ったもの
サンプルコードはこちら
以下の説明では部分的に抜粋して説明するので、全体を見たい方はコードの方を見てください。
ユーティリティ側
Transitable
画面遷移に必要な情報をまとめたデータです。
// 遷移情報をまとめた構造体
struct Transitable<T: UIViewController, S: TransitionDependencies> {
let vcType: T.Type // ViewControllerのクラス、遷移によっては必要ないこともある
let dependencies: S // 次の画面に渡すべきデータ
let type: TransitionType // 遷移種別
let withNavigationController: Bool // NavigationControllerをつけるか否か
let withoutStoryboard: Bool // Storyboardを使わないか否か
}
extension Transitable {
// 多いパターンのためのイニシャライザ
// NavigationControllerはつけないことが多い
// Storyboardは使うことが多い
init(vcType: T.Type, dependencies: S, type: TransitionType) {
self.vcType = vcType
self.dependencies = dependencies
self.type = type
self.withNavigationController = false
self.withoutStoryboard = false
}
}
// 次の画面に渡すべきデータのためのプロトコル
// 中身は空だが、型に縛りをつけたいので使う
protocol TransitionDependencies {}
// 渡すデータがない場合に使う
struct EmptyTransitionDependencies: TransitionDependencies {}
// 前の画面からデータをもらうためのプロトコル
protocol TransitionDependenciesAcceptable {
func setDependencies<T: TransitionDependencies>(dependencies: T)
}
// 前の画面からデータをもらう画面
// 若干だけど短くするため宣言
typealias TransitionDependenciesAcceptableViewController = UIViewController & TransitionDependenciesAcceptable
// 遷移種別
enum TransitionType {
case root // windowのrootViewControllerを置き換える
case tab(index: Int) // タブ切り替え
case push(animated: Bool) // NavigationControllerの進む
case pop(animated: Bool) // NavigationControllerの戻る
case present(animated: Bool, completion: (() -> Void)?) // 開く
case dismiss(animated: Bool, completion: (() -> Void)?) // 閉じる
}
TransitableView
Transitableを受けて画面遷移を実行する部分。
誰が画面遷移の役割を担うかは論点になりえるところですが、ここではView(ViewController)がその役割を担うことにします。
// 画面遷移するViewControllerが適合するプロトコル
protocol TransitableView {
func transit<T: UIViewController, S: TransitionDependencies>(_ transitable: Transitable<T, S>)
}
// やることは決まっているのでprotocol extensionで実装する
extension TransitableView where Self: UIViewController {
// 画面遷移を実行するメソッド
func transit<T: UIViewController, S: TransitionDependencies>(_ transitable: Transitable<T, S>) {
switch transitable.type {
case .root:
let vc = self.instantiateVC(transitable)
UIApplication.shared.keyWindow?.rootViewController = vc
case .tab(let index):
// タブの場合はTabControllerを持ってるやつに向けて通知を送る
NotificationCenter.default.post(name: .tabWillSwitch, object: nil, userInfo: [Notification.ObjectKey.index: index])
case .push(let animated):
let vc = self.instantiateVC(transitable)
self.navigationController?.pushViewController(vc, animated: animated)
case .pop(let animated):
self.navigationController?.popViewController(animated: animated)
case .present(let animated, let completion):
let vc = self.instantiateVC(transitable)
self.view.window?.rootViewController?.present(vc, animated: animated, completion: completion)
case .dismiss(let animated, let completion):
self.dismiss(animated: animated, completion: completion)
}
}
// ViewControllerを生成し、必要ならデータを渡すメソッド
private func instantiateVC<T: UIViewController, S: TransitionDependencies>(_ transitable: Transitable<T, S>) -> UIViewController {
let vc: UIViewController = transitable.withoutStoryboard ? T() : T.instantiate()
if !(transitable.dependencies is EmptyTransitionDependencies) {
guard let acceptableVC = vc as? TransitionDependenciesAcceptableViewController else {
fatalError("\(vc) doesn't confirm TransitionDependenciesAcceptableViewController")
}
acceptableVC.setDependencies(dependencies: transitable.dependencies)
}
return transitable.withNavigationController ? UINavigationController(rootViewController: vc) : vc
}
}
使う側
Model
アプリケーションで使う画面遷移を、Transitableとして表現します。
final class Transitions {
typealias SimplePop = Transitable<UIViewController, EmptyTransitionDependencies>
static var simplePop: SimplePop {
return Transitable<UIViewController, EmptyTransitionDependencies>(
vcType: UIViewController.self,
dependencies: EmptyTransitionDependencies(),
type: .pop(animated: true)
)
}
typealias SimpleDismiss = Transitable<UIViewController, EmptyTransitionDependencies>
static var simpleDismiss: SimpleDismiss {
return Transitable<UIViewController, EmptyTransitionDependencies>(
vcType: UIViewController.self,
dependencies: EmptyTransitionDependencies(),
type: .dismiss(animated: true, completion: nil)
)
}
}
渡すべきデータがある場合は一緒に定義しておきます。
// 画面Cで必要になるデータ
struct CDependencies: TransitionDependencies {
let text: String
let isPresented: Bool
let isPushed: Bool
}
extension Transitions {
typealias ToC = Transitable<CViewController, CDependencies>
// 画面Cにpresentで遷移
static func presentC(text: String) -> ToC {
return Transitable<CViewController, CDependencies>(
vcType: CViewController.self,
dependencies: CDependencies(text: text, isPresented: true, isPushed: false),
type: .present(animated: true, completion: nil)
)
}
// 画面Cにpushで遷移
static func pushC(text: String) -> ToC {
return Transitable<CViewController, CDependencies>(
vcType: CViewController.self,
dependencies: CDependencies(text: text, isPresented: false, isPushed: true),
type: .push(animated: true)
)
}
}
プレゼンテーションロジック(PresenterなりViewModelなり)
ボタンが押されたなどのイベントをViewから受け取って、Transitionを返します。
final class CPresenter {
var view: CViewController
init(view: CViewController) {
self.view = view
}
}
extension CPresenter {
// ボタンが押された時に呼ばれる
func dismissDidTap() {
self.view.transit(Transitions.simpleDismiss)
}
// ボタンが押された時に呼ばれる
func popDidTap() {
self.view.transit(Transitions.simplePop)
}
}
ビュー
ある場合は前の画面からのデータを受け取り保持しておきます。
ボタンが押されたなどのイベントをロジックを担当しているクラスに渡します。
final class CViewController: TransitionDependenciesAcceptableViewController, TransitableView {
@IBOutlet private weak var fromLabel: UILabel!
@IBOutlet private weak var dismissButton: UIButton!
@IBOutlet private weak var popButton: UIButton!
lazy var presenter: CPresenter = CPresenter(view: self)
private var dependencies: CDependencies?
// 前の画面からデータをもらうためのメソッド
func setDependencies<T: TransitionDependencies>(dependencies: T) {
self.dependencies = dependencies as? CDependencies
}
override func viewDidLoad() {
super.viewDidLoad()
self.setupUI()
}
// 前の画面からもらったデータをセット
private func setupUI() {
self.fromLabel.text = self.dependencies?.text
self.dismissButton.isEnabled = self.dependencies?.isPresented ?? false
self.popButton.isEnabled = self.dependencies?.isPushed ?? false
}
}
extension CViewController {
// ボタンが押された時に呼ばれる
@IBAction private func dismissDidTap() {
self.presenter.dismissDidTap()
}
// ボタンが押された時に呼ばれる
@IBAction private func popDidTap() {
self.presenter.popDidTap()
}
}
イマイチな点
Transitable
を使う側が型パラメータを意識しないといけない
本当はTransitable
をgeneric protocolにしたいのですが、そうしてしまうと型名として使えなくなってしまうためにstruct
にしています。
Modelを作る時に型パラメータをしっかり書かないといけないのが面倒だし、見た目がごちゃっとしてしまうのがなんだか残念です。
使わない場合でもTransitable
の型パラメータを渡さなければいけない
dismiss
やpop
などでは、ViewControllerは生成しないし、データ渡しをすることも少ないので、型パラメータは不要な場合が多いです。しかし、いかなる場合でも型パラメータを指定しなければならないので、意味のないUIViewController
やEmptyTransitionDependencies
を書く必要があります。
前の画面からデータを受け取る時にキャストが必要
TransitionDependenciesAcceptable
ではTransitionDependencies
に適合するなにかを受け取るという指定しかできません。
実際に画面で使う場合は、その画面のデータにキャストしてやる必要があります。そのため、optionalなプロパティにするか、force unwrapを使うかしないといけないところがイマイチです。
タブ遷移を完全にハンドリング出来ていない&Notification
使用
コードからタブ遷移をさせる場合は問題ないですが、タブをタップされたときはTransitable
が生成されない遷移が起きます。
コードからの場合は、TabControllerを持っている誰かにNotification
を送るという作りになっているので、通知を受け取る処理を書かなければいけません。
終わりに
画面遷移もモデルとして扱うという発想はそんなに悪いものでは無いと思うのですが、コードが結構複雑になってしまいました
もし改善案がありましたら、ぜひコメントしていただけると幸いです