UICollectionViewの横画面対応・回転対応 〜 レイアウト崩れに対応する

いわゆる横画面対応など、画面回転時のUICollectionViewのレイアウト崩れについて。
こんな感じで対応した、というのをまとめておく。

# よくある4カラムの実装（修正前）

端末の向き固定で使う分には問題はないが……。

```swift
class CollectionViewController: UIViewController {

    private var collectionView: UICollectionView!
    private var layout: UICollectionViewFlowLayout!

    override func viewDidLoad() {
        super.viewDidLoad()

        // レイアウトを決める
        layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 16
        layout.minimumLineSpacing = 16
        let itemCount: CGFloat = 4
        let itemWidth: CGFloat = (view.bounds.width - (itemCount - 1) * 16) / itemCount
        layout.itemSize = CGSize(width: itemWidth, height: itemWidth)

        // UICollectionViewを初期化
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")
        collectionView.dataSource = self
        collectionView.backgroundColor = .white

        // CollectionViewしか使わないので、viewを交換しちゃう
        view = collectionView
    }
}

extension CollectionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 30
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
        cell.label.text = String(indexPath.row)
        return cell
    }
}

class Cell: UICollectionViewCell {
    var label: UILabel!

    override init(frame: CGRect) {
        super.init(frame: frame)

        // サブビューをインスタンス化して配置
        label = UILabel(frame: contentView.bounds)
        label.backgroundColor = .systemBlue
        contentView.backgroundColor = .yellow
        contentView.addSubview(label)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }
}
```

## 回転してみる

Portraitで起動し、Landscapeへ。**4カラムだったのが、5カラムに。しかも、アイテムのスペースが変わってしまった。**

![名称未設定.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/8898968f-a3bf-83f1-e5c4-91f3bcd5a536.jpeg)

Landscapeで起動して、Portraitに回転させると……。**4カラムだったのが3カラムになってしまった……。**

![名称未設定.002.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/ee7a0699-b209-ebcc-bdd9-0ab5b46fa206.jpeg)

# 実装を直す

とりあえずレイアウト関連の処理は、`viewWillLayoutSubviews()`に移動する。これだけで、カラム数やアイテムサイズは意図したとおりになる……！

```swift
override func viewDidLoad() {
    super.viewDidLoad()

    // UICollectionViewFlowLayoutをインスタンス化
    layout = UICollectionViewFlowLayout()

    // UICollectionViewを初期化
    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
    collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")
    collectionView.dataSource = self
    collectionView.backgroundColor = .white

    // CollectionViewしか使わないので、viewを交換しちゃう
    view = collectionView
}

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()

    // レイアウト関連は、ここでやる
    layout.minimumInteritemSpacing = 16
    layout.minimumLineSpacing = 16
    let itemCount: CGFloat = 4
    let itemWidth: CGFloat = (view.bounds.width - (itemCount - 1) * 16) / itemCount
    layout.itemSize = CGSize(width: itemWidth, height: itemWidth)
}
```

これがベストな方法かはいまいちはっきりしません……。
きっと、独自で`UICollectionViewFlowLayout`を継承したクラスを作るのがいいんでしょうね……。

とはいえ**これでOK……と思いきや！！！**
回転したとき、Cellの`contentView`は4カラムを維持するようにリサイズされたが、Labelがリサイズされていない……！

Portraitで起動し、Landscapeへ。**contentViewの黄色が見えてしまった。**

![名称未設定.003.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/1204b139-2b9e-62ae-3fd3-9e7a0c960c39.jpeg)

Landscapeで起動し、Portraitへ。**UILabelにつけた青色で埋め尽くされてしまった……。**

![名称未設定.004.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/7d7bc230-92c6-3562-a742-cfa4de53d718.jpeg)

斜め左上から見ると、こんな状況。

<img width="601" alt="スクリーンショット 2019-12-07 21.24.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/ae180368-2269-f2d8-dbd4-0df9c4f33d7e.png">

青の`label`は、リサイズされずに隣り合ったセルと重なり合っている。
黄色の`contentView`だけはリサイズされて4カラムサイズの正方形に。

つまり、**Cellクラスでaddしたviewが、リサイズされていない**

# UICollectionViewCellの修正

subViewをレイアウトするときに、場所を決めてあげれば良い。
レイアウトの処理は、`layoutSubviews`に移動する。UIViewControllerのときと考え方は一緒。

```swift
class Cell: UICollectionViewCell {
    var label: UILabel!

    override init(frame: CGRect) {
        super.init(frame: frame)

        // サブビューはインスタンス化するだけ、場所は決めない
        label = UILabel()
        label.backgroundColor = .systemBlue
        contentView.backgroundColor = .yellow
        contentView.addSubview(label)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // レイアウトの処理を移動してきた
        label.frame = contentView.bounds
    }
}
```

もしくは、`autoresizingMask`を適切に設定すればOK。

```swift
override init(frame: CGRect) {
    super.init(frame: frame)

    // サブビューをインスタンス化して配置
    label = UILabel(frame: contentView.bounds)
    label.backgroundColor = .systemBlue
    label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    contentView.backgroundColor = .yellow
    contentView.addSubview(label)
}
```

これでCellサイズぴったり。

![名称未設定.005.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/6ad1e9f8-018a-8dc0-fd5a-7f6b616bfb84.jpeg)

# 他の画面で端末を回転して戻ってきた場合も大丈夫

CollectionViewの画面に戻ってくるタイミングで、Cellの`layoutSubviews()`が呼ばれるので、全く問題ない。

## UINavigationControllerが親のとき

プッシュ → 回転 → バックしても、ちゃんとレイアウトが更新されている！

![名称未設定2.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/e3ef7ea1-8018-a55f-b1a1-e1beddbe4e53.jpeg)

## UITabBarControllerが親のとき

他のタブへ行く → 回転 → 元のタブに戻る。問題ない、レイアウトが更新されている！

![名称未設定2.002.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75329/d32ec0b7-12ab-3d8e-4a5d-75e513e0b6a9.jpeg)

↑見えにくいが、タブが追加されている

# それでも大丈夫じゃない場合

大抵の場合、UIViewのライフサイクルに沿った実装になっていないことが多い。
でも、改修が困難なこともあるので。

## 対応策

あまりやりたくはないが、以下の方法がある。

1. `collectionView.reloadData()`を呼ぶ
1. `collectionView.collectionViewLayout.invalidateLayout()`を呼ぶ

呼ぶタイミングは、

1. `viewWillAppear(_:)`
2. `viewWillTransition(to:with:)`

のいずれか。

## reloadData()

```swift
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    collectionView.reloadData()
}
```

## invalidateLayout()

```swift
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    collectionView.collectionViewLayout.invalidateLayout()
}
```

# 以上

こんな感じで、レイアウト崩れにはとりあえず対応できるんじゃないかと！