MILLEN BOX 2

個人iOSアプリ開発者hollymotoによる勉強の記録。時々雑記。

StoryboardでDIして画面遷移(自分なりに)

こんにちは。hollymoto@anthrgrnwrld です。
Storyboardを使って画面遷移する場合、私には悩みがありました。 その感じていた悩みを思いつくまま羅列してみます...。

  • 遷移元ViewControllerから遷移先ViewControllerへの移行時への値の渡し方が何か嫌

  • 遷移先ViewControllerが作られた時に渡された値を格納する変数を var で定義しなければならないのが嫌

  • 遷移先ViewControllerへの遷移方法が複数ありそれにより初期処理が異なる場合、判別以外には役に立たない変数を用意しなければならないのが嫌

  • 遷移先ViewControllerへの遷移方法が複数あり、それにより遷移元ViewControllerへのBack時の処理が異なる場合、そもそも遷移先ViewControllerへの遷移時にリターンした時の処理を指定できないのが嫌

などなど...。

この悩み以下の方法で解決できるかもです。

  • protocolを使用してインターフェースをしっかり定義して

  • 遷移方法毎のconfigをprotocolを継承したstructで定義して

  • iOS13から出来る様になったStoryboardでのDI(Dependency Injection|依存性の注入)を使う。

上記を「StoryboardでオレオレDI」と名付けました。

今回は「StoryboardでオレオレDI」の方法のメモ書きを残します(自分の中でもまだ煮詰められていないので備忘録的な扱いです)。

目次

参考ページ

めちゃくちゃ参考にしたページ群は以下。

[Swift] DIって何? 実践編 - Qiita

iOS13ではStoryboardでもDIができる件について - Qiita

0. 基本的な考え方

実は出来上がるものは以下のページで紹介しているものと変わりません。
www.millenbox2.com
しかし!今回はやり方が異なります。

今回追加した要素は以下の部分です。

  1. 遷移時に絶対必要なプロパティ群をprotocolを使ってインターフェースとして定義する

  2. 1のprotocolを継承した構造体を作成し、遷移方法によって初期化時のプロパティ値を適したものにする

  3. リターン方法を1のprotocolで定義しているクロージャーに登録することとし、遷移元から指定出来る様にする

  4. 遷移先ViewControllerでDI用クラスを作成(注入する情報は2と3のクロージャー)

  5. 遷移先ViewControllerでinit時の処理としてDI用クラスの初期化を行う様にする

  6. 遷移元からの遷移方法の変更

1. Config protocolの作成

遷移時に絶対必要なプロパティ群をprotocolを使ってインターフェースとして定義しました。

protocol DetailConfig {
    var isNewItem: Bool { get }                                     // 新規作成か編集か(遷移元から取得)
    var targetIndexPath: IndexPath { get }                          // 編集対象のIndexPath情報(遷移元から取得)
    var tapDateObject: TapDateObject { get }                        // targetIndexPathから取得
    var willBackToPrevViewControllerClosure: () -> Void { get set } // 遷移元にリターンする時に実行するクロージャー
    mutating func setClosure(willBackToPrevViewController: @escaping () -> Void)    // クロージャーのセットアップメソッド
}

2. 1のprotocolを継承したNew Item用とEdit用の構造体をそれぞれ作成

  • iNew ItemだったらisNewItemはtrueだけど、Editだったらfalseだよねーとか。
  • iEditだったら遷移元からタップしたindexPathは欲しいけど、New Itemの場合はrowが0だから気にしないでいいよねーとか。
  • iNew ItemだろうがEditだろうが、表示項目の準備をしといた方がいいよねー。New Itemの場合はRealmに登録予定のオブジェクトを作成しとく方がいいし、Editの場合にはRealmから対応するindexPathのオブジェクトを取得しとく方がいいよねーとか。

今ここであげたことを気にしてNew Item用とEdit用の構造体をそれぞれ作る。

New Item用(=AddボタンでこのVCに遷移してきた場合)

struct NewConfig: DetailConfig, RealmPrimaryKeyIncrementerProtocol {
    let isNewItem = true
    let targetIndexPath = IndexPath(row: 0, section: 0)
    let tapDateObject: TapDateObject
    var willBackToPrevViewControllerClosure: () -> Void
    
    init() {
        let nowDate = Date()
        self.tapDateObject = TapDateObject(date: nowDate)
        self.willBackToPrevViewControllerClosure = {}
        tapDateObject.id = newId(model: tapDateObject)
    }
    
    mutating func setClosure(willBackToPrevViewController: @escaping () -> Void) {
        willBackToPrevViewControllerClosure = willBackToPrevViewController
    }
}

Edit用(=TableViewCellをタップしてこのVCに遷移してきた場合)

//TableViewCellをタップしてこのVCに遷移してきた場合
struct EditConfig: DetailConfig {
    let isNewItem = false
    let targetIndexPath: IndexPath
    let tapDateObject: TapDateObject
    var willBackToPrevViewControllerClosure: () -> Void
    
    init(targetIndexPath: IndexPath) {
        let realm = try! Realm()
        let tapDateList = realm.objects(TapDateList.self)
        self.tapDateObject = tapDateList.first!.list[targetIndexPath.row]
        self.targetIndexPath = targetIndexPath
        self.willBackToPrevViewControllerClosure = {}
    }
    
    mutating func setClosure(willBackToPrevViewController: @escaping () -> Void) {
        willBackToPrevViewControllerClosure = willBackToPrevViewController
    }
}

3. 遷移タイミングで遷移元似てBack時の処理を登録する仕組みを作成

2のコードで既に書いちゃっているけど setClosure の部分ですね。このメソッドは遷移元でconfigのオブジェクト作った直後に実行必須とします。

mutating func setClosure(willBackToPrevViewController: @escaping () -> Void) {
    willBackToPrevViewControllerClosure = willBackToPrevViewController
}

4. DI用クラスの作成

1から3を使って、遷移先のみで使う為(=private)にDI用のクラスを作成。
使い方は5で説明。

private class DetailViewControllerDI: NSObject {
    let config: DetailConfig
    
    init(config: DetailConfig) {
        self.config = config
        super.init()
    }
    
    fileprivate func configureView(detailVC: DetailViewController) {
        detailVC.detailDescriptionLabel.text = config.tapDateObject.objDescription
    }
    
    fileprivate func willBackToPrevViewController() {
        config.willBackToPrevViewControllerClosure()
    }
    
}

5. DI用クラスの使用方法(遷移先ViewControllerのinit時の処理)

現在最適と考える使用方法はinitのとこで必須の引数としてDetailConfig型の変数を定義して、遷移元から渡されたNewConfig構造体型 or EditConfig構造体型の値を遷移先クラスのプロパティに保存することだと思う。この方法、何が嬉しいかってしょーもないプロパティをvarとかで定義しなくてもよくなるからなんですよね。
Storyboardで紐付けられてるVIewControllerでinit処理が出来る様になるのはiOS13以降からみたい。ここ注意ポイントですね。VIewControllerでinit出来ると何が嬉しいかっていうと引き継がれたものだからって無闇にvarにしなくてもいいから!
RealmPrimaryKeyIncrementerProtocolなど関係ない記述も書いてあるけど、これはこの記事を引き継いでるからです。時間がある人は見てみて下さい。)

class DetailViewController: UIViewController, RealmPrimaryKeyIncrementerProtocol {
    
    // MARK: - IBOutlet
    @IBOutlet weak var detailDescriptionLabel: UILabel!    // Storyboardで紐付けられてるUILabel。New Item遷移とEdit遷移で表示する内容が異なるのでDIで対処必要
    
    // MARK: - Private Property
    private let initConfig: DetailConfig        // 2で定義した遷移元から渡される構造体のプロパティ
    private let DI: DetailViewControllerDI   // initConfigをよしなにするDIクラス
    
    // MARK: - Init     // iOS13以上
    init?(coder: NSCoder, initConfig: DetailConfig) {
        
        self.initConfig = initConfig
        self.DI = DetailViewControllerDI(config: self.initConfig)
        
        super.init(coder: coder)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        DI.configureView(detailVC: self)
        navigationController?.delegate = self   //遷移(From/In)のフックの為にdelegateをセット
    }

}

6. 遷移元からの遷移方法

こんな感じです。
この記事から単純な変更点としてSegueではなく、コードを使って遷移する様にしました。)

extension ListViewController {
    
    /**
     NewでDetailViewControllerへ遷移する (=Addボタン押下)
    */
    @objc
    private func shiftToNewDetailVC(_ sender: Any) {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        
        var newConfig = NewConfig()    // ここポイント!
        
        newConfig.setClosure(willBackToPrevViewController: {
            let realm = try! Realm()
            let tapDateList = realm.objects(TapDateList.self)
            try! realm.write {
                if tapDateList.first == nil {
                    let tmpTapDateList = TapDateList()
                    tmpTapDateList.list.append(newConfig.tapDateObject)
                    realm.add(tmpTapDateList)
                } else {
                    tapDateList.first!.list.insert(newConfig.tapDateObject, at: newConfig.targetIndexPath.row)
                }
            }
            
            self.performToInsertAction(IndexPath(row: 0, section: 0))
        })

        let targetViewController = storyboard.instantiateViewController(identifier: "DetailViewController", creator: { coder in
            DetailViewController(coder: coder, initConfig: newConfig)
        })
        self.navigationController?.pushViewController(targetViewController, animated: true)
    }
    
    /**
     EditでDetailViewControllerへ遷移する (=TableViewCell押下)
    */
    private func shiftToEditDetailVC(indexPath: IndexPath) {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        
        var editConfig = EditConfig(targetIndexPath: indexPath)    // ここポイント!
        
        editConfig.setClosure {
            self.tableView.reloadData()
        }
        
        let targetViewController = storyboard.instantiateViewController(identifier: "DetailViewController", creator: { coder in
            DetailViewController(coder: coder, initConfig: editConfig)
        })
        
        self.navigationController?.pushViewController(targetViewController, animated: true)
    }
    
}

7. まだ残る不満点

以下の項目等がまだ不満として残ってる。

  • setClosureの内容を構造体のinitの時にやりたいんだけど現状出来てない(New ItemでsetClosureの内容を実行する時にはどうしてもLabelに表示しているオブジェクトが必要で、これは構造体のinit時にはまだ確定してないから同時には出来ないという理解をしている)

  • 遷移先から遷移元へBackする方法をdelegateで引っ掛ける方法しか現状思い付かず、ダサく感じている

8. まとめ

不満点は残るものの色々新しいことも理解出来て有意義だったなーと感じています。