巨大なビューをStoryboardだけで表現する

iOSアプリの画面を実装する場面において、

1. 映画作品の詳細を紹介する画面
2. Photoshopのような、複数レイヤで構成される画像の編集画面

このような多数のUIコンポーネントを配置しなければいけない画面があったとき、あなたはどのようにレイアウトを構築しますか？

多くの方はUIコンポーネント群を、何かの単位(1.の例であれば詳細画面のセクションごとなど)で分類し、場合によってはその中も再分割を行うことで、構造的に分解することを考えるのではないかと思います。

ここではその一例として、`Storyboard`と`Container View`を使った構造化された画面設計について考えてみたいと思います。

<img width="30%" src="https://qiita-image-store.s3.amazonaws.com/0/25727/3966862f-06b0-e340-0d96-77c474361cb7.gif">

[サンプルコード: StructuredViewControllerSample](https://github.com/imk2o/StructuredViewControllerSample)

## Container Viewを使ってビュー(コントローラ)を構造化する

Storyboardを使うと、`Container View`というコンポーネントを利用することで、親子関係を持つビューコントローラ(以下、VCと略します)を作ることができます。これはつまり、多数のUIコンポーネントを要する画面を、複数のビューコントローラに分解して管理することができることを表しています。

<img src="https://qiita-image-store.s3.amazonaws.com/0/25727/b193e408-4677-8942-c7ef-76a5281c5abb.png">

### Container ViewのSelf-sizing

Container Viewは、何もしないと自身に付けられた大きさの制約に従ってレイアウトされます。中の子VCが期待するサイズにSelf-sizingしたい場合は、以下を行う必要があります。

* 子VCの`view.translatesAutoresizingMaskIntoConstraints`に`false`を設定する

```swift:ChildViewController.swift
class ChildViewController: UIViewController {
    override func loadView() {
        super.loadView()
        self.view.translatesAutoresizingMaskIntoConstraints = false
    }

    ...
}
```

* Container View自体に付与している大きさの制約を削除する
    - Interface Builder上で制約のエラーが発生する場合、`Intrinsic Size`を`Placeholder`に変更する

<img width="259" src="https://qiita-image-store.s3.amazonaws.com/0/25727/0347ca92-af2a-05c3-e611-5f8e0264f616.png">

### 画面の一部を「リスト」化するには

例えば、ある映画作品の詳細画面にユーザレビューを最大3件表示する必要があったとき、Container Viewの集合で作られた画面ではどのように実現したらいいでしょうか？

* 3つのContainer Viewを作り、その参照先をすべてレビューを表示するVCに向ける
* 要素の数だけ伸張する「スクロールしないTable View」を作る

2〜3件程度であれば前者の方法でも良さそうです(サンプルコードの「セクション」はこの方法で実装)が、件数が不定の場合は後者の方法が最適でしょう。

`Scrolling Enabled` がオフのとき、全てのセルが表示される大きさに伸長するようなTable Viewを作るには、以下のような継承クラスを定義するといいでしょう。

```swift:IntrinsicTableView.swift
class IntrinsicTableView: UITableView {
    override var contentSize: CGSize {
        didSet {
            if !self.isScrollEnabled {
                self.invalidateIntrinsicContentSize()
            }
        }
    }
    
    override var intrinsicContentSize: CGSize {
        if self.isScrollEnabled {
            return super.intrinsicContentSize
        } else {
            self.layoutIfNeeded()
            return CGSize(
                width: UIView.noIntrinsicMetric,
                height: self.contentSize.height
            )
        }
    }
}
```

> 参考: https://stackoverflow.com/questions/2595118/resizing-uitableview-to-fit-content/48623673#48623673

### Container Viewの参照先のロードを遅延させるには

Container Viewは、`hidden`を設定していたとしても、参照先の子VCは必ずロードされてしまいます。コンテンツによって構成が変わるような画面では、効率が悪くパフォーマンスも低下します。

この子VCの読み込みは、 `shouldPerformSegue(withIdentifier:sender:)` で制御することができます。そして遅延させた子VCを読み込む場合は `performSegue(withIdentifier:sender:)` を明示的に呼び出します。

```swift:ContentViewController.swift
    override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
        if identifier == "onDemandView" {
            return false
        } else {
            return true
        }
    }

    ...

    func loadPendingViews() {
        // 読み込み済のSegue Identifierを指定するとクラッシュするので要
注意！
        self.performSegue(withIdentifier: "onDemandView", sender: self)
    }
```

## より実践的な課題

### 子VCから、オーナーVCを参照するには

すべての子・孫VCを包含する、**オーナーVC**を特定するには、子・孫VCから `self.parent` を辿っていけば良さそうなのですが、`self.parent` が `nil` を返す `self` がオーナーVCとは限りません。オーナーVCが、 `Navigation Controller` や `Tab Bar Controller` 等に包含されている場合があるためです。

そこで、子・孫VCであることを宣言するプロトコルを定義し、オーナーVCを容易に参照できる拡張を追加します。

```swift:StructuredProtocol.swift
protocol StructuredChildProtocol: StructuredProtocol {
    associatedtype OwnerViewController: UIViewController
}

extension StructuredChildProtocol where Self: UIViewController {
    var ownerViewController: OwnerViewController? {
        return self.parent(of: OwnerViewController.self)
    }
    
    private func parent<VC: UIViewController>(of type: VC.Type) -> VC? {
        var parent = self.parent
        while parent != nil {
            if let typedParent = parent as? VC {
                return typedParent
            }
            parent = parent?.parent
        }
        
        return nil
    }
}
```

### 子VCから親VCを参照するときの注意点

子VCの `loadView()` は、親VCの `prepare(for:sender:)` の後に呼び出されるため、親から子へのデータの受け渡しは比較的問題なく行えます。一方、子VCから親やオーナーVCが持つデータの参照はやや難しくなります。

その理由が、 `loadView()`, `viewDidLoad()`の呼び出される順序と、 `self.parent` が参照できるタイミングです。

* `loadView()`: 親→子→孫の順
* `viewDidLoad()`: 孫→子→親の順
* `loadView()`, `viewDidLoad()` の時点では `self.parent` は `nil` を返す
    - `didMove(toParentViewController:)` がコールされた後に `self.parent` が設定される。但しこのメソッドも孫→子の順に呼ばれるため、`self.parent?.parent` は `nil` となる。

以上のことから、これらのライフサイクル・コールバックにおいてはVC階層は完成しておらず、子VCにおいて、先の `ownerViewController` の参照がうまくできません。

解決策として、先の `StructuredChildProtocol` プロトコルを以下のように変更した上、オーナーVCも `StructuredProtocol` プロトコルへ準拠させます。

```swift:StructuredProtocol.swift
protocol StructuredProtocol: class {
    func configureSelf()
}
typealias StructuredViewController = UIViewController & StructuredProtocol

extension StructuredProtocol where Self: UIViewController {
    func configure() {
        self.configureSelf()
       
        self.children
            .compactMap { $0 as? StructuredViewController }
            .forEach { $0.configure() }
    }
    
    func configureSelf() {}
}

protocol StructuredChildProtocol: StructuredProtocol // class から変更 {
    associatedtype OwnerViewController: UIViewController
}

// ほかは変更なし
```

こうすると、オーナーVCの `viewDidLoad()` 内で `configure()` を呼び出すことで、全ての子・孫VCのセットアップ処理を指示することができます。子・孫VCは `configureSelf()` の中で `ownerViewController` を参照することができます。

### 親・子VC間の依存関係とやりとりの検討事項

採用するアーキテクチャにもよりますが、さらに以下の観点で設計すると良いでしょう。

* 子VCは極力親に依存せず、自身の責務を果たすようにする
* 同様に親VCは子に機能や振る舞いを委譲する
* とはいえ、多くの場合は関連性のあるものを扱うはずなので、無理に低依存にする必要もない
* `prepare(for segue:, sender:)`でコンテキスト(ViewModelやPresenter)を渡す
    - あるイチ機能に対応する画面を分解したのであれば、その全体を表すVMやPresenterで考えてもよさそう
* 子の変化は親VCのメソッドを直接呼ぶのではなく、Delegateや共有したVMの監視などで行う

### XIBを使う必要があるケース

StoryboardとContainer Viewを用いたレイアウトの構造的表現は、実現したい複雑な画面を要素分解する場合に向いていると言えます。一方XIBは、以下のような場合に使う必要があるでしょう。

* 共有するTable View CellやCollection View Cellがある
* Table Header/Footer View
* Table View CellとVCへの埋め込みのどちらにも利用できるレイアウトを作りたいとき
* Storyboardの同時編集で競合を避けたいとき