UICollectionViewのカスタムレイアウトの作り方

UICollectionViewでは、UICollectionViewLayoutのサブクラスを作成することで、独自のレイアウトを定義することができます。
本記事では、カスタムレイアウトの作り方を紹介します。

なお、本記事では説明に必要な箇所のコードのみ掲載しています。
ベースとなる実装については以下の記事を参照してください。

[UICollectionViewを必要最低限のコードで実装してみる](https://qiita.com/takehilo/items/4af35623aadf3c59dc65)

## 作成するレイアウト
ランダムな大きさのセルを積み木のように積み立てたレイアウトを作成していきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/a6d06ce4-6dae-1b66-cf85-9298c82363a3.png)

## レイアウト処理の流れを理解する
まず、レイアウト処理を司る3つのプロパティおよびメソッドについて理解する必要があります。

### prepare()メソッド
セルの配置を決めるために必要となる計算をする場所です。
セルの大きさや位置を計算し、あとで使用するためにそれらを保持しておきます。

### collectionViewContentSizeプロパティ
コンテンツ全体を収容する領域の大きさを返すゲッタープロパティです。
領域の大きさが画面サイズを超える場合、横方向および縦方向どちらにもスクロールできるように設定されます。

### layoutAttributesForElements(in: CGRect)メソッド
コレクションビューは現在のスクロール位置に基づき、以下のようにある矩形(Visible rectの部分)を指定してその中にあるセルの属性を問い合わせます。
引数の`in`にはその矩形のサイズ、位置情報が入っています。
ここでいう属性とはUICollectionViewLayoutAttributesインスタンスのことで、セルの大きさ、位置、重なり順などの情報を持つオブジェクトです。
ここで返された属性のリストをもとに、コレクションビューはセルの配置を行います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/8f12958e-d3bd-ebce-d97c-6904f3f4cb0d.png)

これら以外にもメソッドは定義されており、それらをオーバーライドすることでより高度なレイアウトを作成することが可能です。
本記事では上記3つのプロパティおよびメソッドについてのみ触れます。

## カスタムレイアウトクラス
カスタムレイアウトクラスの実装は以下の通りです。
UICollectionViewLayoutを継承したCustomLayoutクラスと、CustomDelegateプロトコルを定義しています。
CustomLayoutでは、先程挙げた３つのプロパティ、メソッドに加え、いくつか追加のプロパティを定義しています。
CustomDelegateプロトコルは、セルに表示するデータに関する情報を取得するためにデリゲートオブジェクトが実装するべきメソッドを定義しています。

```swift:CustomLayout.swift
import UIKit

class CustomLayout: UICollectionViewLayout {
    weak var delegate: CustomDelegate!
    var numColumns = 4
    var padding: CGFloat = 3
    var attributesArray = [UICollectionViewLayoutAttributes]()
    var contentHeight: CGFloat = 0

    var contentWidth: CGFloat {
        guard let collectionView = collectionView else { return 0 }
        return collectionView.bounds.width
    }

    override var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: contentHeight)
    }

    override func prepare() {
        guard attributesArray.isEmpty, let collectionView = collectionView else { return }

        let columnWidth = contentWidth / CGFloat(numColumns)
        var xOffsets = [CGFloat]()
        for column in 0..<numColumns {
            xOffsets.append(columnWidth * CGFloat(column))
        }

        var column = 0
        var yOffsets = [CGFloat](repeating: 0, count: numColumns)

        for item in 0..<collectionView.numberOfItems(inSection: 0) {
            let indexPath = IndexPath(item: item, section: 0)
            let itemHeight = delegate.collectionView(collectionView, heightForItemAt: indexPath)
            let height = itemHeight + padding * 2
            let frame = CGRect(x: xOffsets[column], y: yOffsets[column], width: columnWidth, height: height)
            let insetFrame = frame.insetBy(dx: padding, dy: padding)

            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = insetFrame
            attributesArray.append(attributes)

            contentHeight = max(contentHeight, frame.maxY)
            yOffsets[column] = yOffsets[column] + height

            column = column < (numColumns - 1) ? (column + 1) : 0
        }
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()

        for attributes in attributesArray {
            if attributes.frame.intersects(rect) {
                visibleLayoutAttributes.append(attributes)
            }
        }
        return visibleLayoutAttributes
    }
}

protocol CustomDelegate: class {
    func collectionView(_ collectionView: UICollectionView, heightForItemAt indexPath: IndexPath) -> CGFloat
}
```

ポイントを整理します。

1. このクラスはCustomDelegate型のデリゲートオブジェクトへの参照を持っています。デリゲートオブジェクトからセルの高さを取得します。
2. セルの配置に関する計算は全て`prepare()`メソッドにて行います。計算結果はUICollectionViewLayoutAttributesの配列として`attributesArray`プロパティで保持します。
3. `prepare()`メソッドでは各セルの高さの計算と並行してコンテンツ全体の高さ`contentHeight`も計算しています。
4. コンテンツの幅`contentWidth`はベースの実装によりデバイスの幅と同じになるように設定されています。
5. `layoutAttributesForElements(in: CGRect)`メソッドでは、画面に表示されている矩形領域(可視領域)にあるセルの属性情報を返すようにしています。`attributes.frame.intersects(rect)`はセルの位置と可視領域が交差しているか、つまりセルが画面に表示されるかどうかの判定をしており、trueであればそのセルの属性情報を返します。

わかりやすくするために、上記のコード中に出てくるプロパティやローカル変数が指している部分を図式化しました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/46ce74db-a7de-6962-c212-823b2cad22d2.png)

## ビューコントローラの実装
ViewControllerをCustomDelegateプロトコルに準拠させます。
以下のエクステンションを実装してください。

```swift:ViewController.swift
extension ViewController: CustomDelegate {
    func collectionView(_ collectionView: UICollectionView, heightForItemAt indexPath: IndexPath) -> CGFloat {
        return CGFloat((arc4random_uniform(11) + 1) * 20)
    }
}
```

ここでは高さの値を20〜200の範囲でランダムに返すような実装をしています。

最後に、コレクションビューにCustomLayoutオブジェクトを設定します。
CustomLayoutオブジェクトを作成し、デリゲートとしてViewControllerを設定したら、それを`collectionView.collectionViewLayout`プロパティに設定します。

以下のコードを`viewDidLoad`メソッドに追記してください。

```swift:ViewController.swift
let customLayout = CustomLayout()
customLayout.delegate = self
collectionView.collectionViewLayout = customLayout
```

以上の実装でアプリを起動すると、カスタムレイアウトが適用されたコレクションビューが表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/a6d06ce4-6dae-1b66-cf85-9298c82363a3.png)