LoginSignup
9
5

More than 3 years have passed since last update.

iOSの画面遷移ユーティリティを晒すので指摘をもらいたい

Posted at

皆さん画面遷移ってどうされてるんでしょうか?😼
私は何度もpresentだのpushだのを書くのがだんだん面倒になってきました:full_moon_with_face:
そこでユーティリティを作ってはみたんですが、イマイチと思うところが多いので、記事で晒すことで指摘をもらおうという作戦です:v:

作ったもの

サンプルコードはこちら
以下の説明では部分的に抜粋して説明するので、全体を見たい方はコードの方を見てください。

ユーティリティ側

Transitable

画面遷移に必要な情報をまとめたデータです。

Utility/Transition/Transitable.swift
// 遷移情報をまとめた構造体
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)がその役割を担うことにします。

Utility/Transition/TransitableView.swift
// 画面遷移する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として表現します。

Model/Transition/Transitions.swift
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)
        )
    }
}

渡すべきデータがある場合は一緒に定義しておきます。

Model/Transition/Transitions+C.swift
// 画面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を返します。

Presentation/C/Presenter/CPresenter.swift
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)
    }
}

ビュー

ある場合は前の画面からのデータを受け取り保持しておきます。
ボタンが押されたなどのイベントをロジックを担当しているクラスに渡します。

Presentation/C/View/CViewController.swift
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の型パラメータを渡さなければいけない

dismisspopなどでは、ViewControllerは生成しないし、データ渡しをすることも少ないので、型パラメータは不要な場合が多いです。しかし、いかなる場合でも型パラメータを指定しなければならないので、意味のないUIViewControllerEmptyTransitionDependenciesを書く必要があります。

前の画面からデータを受け取る時にキャストが必要

TransitionDependenciesAcceptableではTransitionDependenciesに適合するなにかを受け取るという指定しかできません。
実際に画面で使う場合は、その画面のデータにキャストしてやる必要があります。そのため、optionalなプロパティにするか、force unwrapを使うかしないといけないところがイマイチです。

タブ遷移を完全にハンドリング出来ていない&Notification使用

コードからタブ遷移をさせる場合は問題ないですが、タブをタップされたときはTransitableが生成されない遷移が起きます。
コードからの場合は、TabControllerを持っている誰かにNotificationを送るという作りになっているので、通知を受け取る処理を書かなければいけません。

終わりに

画面遷移もモデルとして扱うという発想はそんなに悪いものでは無いと思うのですが、コードが結構複雑になってしまいました:sweat:
もし改善案がありましたら、ぜひコメントしていただけると幸いです:bow::bow::bow:

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5