【Swift】UICollectionViewCompositionalLayoutを使って数種類のカスタムレイアウトを実装してみる

# はじめに
WWDC2019で新たに発表された、UICollectionViewのレイアウト手法である**UICollectionViewCompositionalLayout**を使っていくつかカスタムレイアウトを作ってみました。
※ iOS13以降の環境にて、今回作成したサンプルを動かせます

# UICollectionViewCompositionalLayoutとは
詳細については、WWDCのセッションを参考にして頂ければと思いますが、ざっくり言うと、

- iOS13から**UICollectionViewCompositionalLayout**が登場したことで、**UICollectionViewFlowLayout**や**UICollectionViewDelegateFlowLayout**に加えて、UICollectionViewのレイアウトを定義する方法が1つ増えた。
- iOS13から**UICollectionViewDiffableDataSource**が登場したことで、**UICollectionViewDataSource**を準拠して行なっていたDataSource管理の方法が増えた。（こちらについては本記事では解説していません）

※ UICollectionViewDataSourceのみでDataSource管理を行なった場合でも、UICollectionViewCompositionalLayoutは使用できます。

※ より詳細な情報については、こちらの記事がかなり詳しくまとめてくださっています。
[時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~](https://qiita.com/shiz/items/a6032543a237bf2e1d19)

# 階層構造
UICollectionViewCompositionalLayoutにおける階層構造は、Item、Group、Sectionなどの概念から成り立っています。

<img width="804" alt="スクリーンショット 2020-01-19 20.38.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254298/a068f4ec-4e17-6173-5777-be2f66a9f1fe.png">

# 実装するレイアウト
今回はUICollectionViewCompositionalLayoutを使用して、３種類のレイアウトを実装してみました。
<img width="926" alt="スクリーンショット 2020-01-19 20.43.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254298/efa800d3-92b7-5660-5826-73cf016d6b10.png">

## 1. グリッド形式(3×n)
<img width="200" alt="スクリーンショット 2020-01-19 20.58.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254298/9b08246d-dfd4-108b-594c-f5e3604a6f75.png">

このレイアウトは、赤枠で囲んだ部分のグループを用意し、×nの形式で表示させる属性を持たせたセクションを用意します。

```SectionType.swift
private func gridSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemCount = 3 // 横に並べる数
        let lineCount = itemCount - 1
        let itemSpacing = CGFloat(1) // セル間のスペース
        let itemLength = (collectionViewBounds.width - (itemSpacing * CGFloat(lineCount))) / CGFloat(itemCount)
        // １つのitemを生成
        // .absoluteは固定値で指定する方法
        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                             heightDimension: .absolute(itemLength)))
        // itemを3つ横並びにしたグループを生成
        // .fractional~は親Viewとの割合
        let items = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                          heightDimension: .fractionalHeight(1.0)),
                                                       subitem: item,
                                                       count: itemCount)
        // グループ内のitem間のスペースを設定
        items.interItemSpacing = .fixed(itemSpacing)

        // 生成したグループ(items)が縦に並んでいくグループを生成（実質これがセクション）
        let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                         heightDimension: .absolute(itemLength)),
                                                      subitems: [items])
        // 用意したグループを基にセクションを生成
        // 基本的にセルの数は意識しない、セルが入る構成(セクション)を用意しておくだけで勝手に流れてく
        let section = NSCollectionLayoutSection(group: groups)

        // セクション間のスペースを設定
        section.interGroupSpacing = itemSpacing
        return section
    }
```

必要なセクション(NSCollectionLayoutSection)を用意したら、それを基にUICollectionViewCompositionalLayoutを生成できます。

```
UICollectionViewCompositionalLayout(section: 生成したSection)
```

あとは用意したUICollectionViewCompositionalLayoutクラスをCollectionViewに割り当てます。

```ViewController.swift
collectionView.collectionViewLayout = 用意したUICollectionViewCompositionalLayout
// 既に構成されているレイアウトを更新する場合は、invalidateLayout()を呼びます。
collectionView.collectionViewLayout.invalidateLayout()
```

<img width="200" alt="CompositionalLayout-Grid.gif" src="https://github.com/ddd503/Image-Resource/raw/master/CompositionalLayout-Grid.gif">

## 2. 異なるサイズのItem併用 (Instagram風)
<img width="230" alt="スクリーンショット 2020-01-19 20.58.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254298/80353b42-045f-f370-5dfb-2dc4df9aa1cf.png">

このレイアウトは、緑枠で囲った各グループを用意して、それらを結合した1つのセクションを用意します。

```SectionType.swift
private func largeAndSmallSquareSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemSpacing = CGFloat(2) // セル間のスペース

        // 小itemが縦に2つ並んだグループ
        let itemLength = (collectionViewBounds.width - (itemSpacing * 2)) / 3
        let largeItemLength = itemLength * 2 + itemSpacing

        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                             heightDimension: .absolute(itemLength)))
        let verticalItemTwo = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(itemLength),
                                                                                                  heightDimension: .absolute(largeItemLength)),
                                                               subitem: item,
                                                               count: 2)
        verticalItemTwo.interItemSpacing = .fixed(itemSpacing)

        // 大item + 小item*2 のグループ
        let largeItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(largeItemLength),
                                                                                  heightDimension: .absolute(largeItemLength)))

        let largeItemLeftGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                       heightDimension: .absolute(largeItemLength)),
                                                                    subitems: [largeItem, verticalItemTwo])
        largeItemLeftGroup.interItemSpacing = .fixed(itemSpacing)

        let largeItemRightGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                        heightDimension: .absolute(largeItemLength)),
                                                                     subitems: [verticalItemTwo, largeItem])
        largeItemRightGroup.interItemSpacing = .fixed(itemSpacing)

        // 小ブロックが縦に2つ並んだグループを横に3つ並べたグループ
        let twoThreeItemGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                      heightDimension: .absolute(largeItemLength)),
                                                                   subitem: verticalItemTwo,
                                                                   count: 3)
        twoThreeItemGroup.interItemSpacing = .fixed(itemSpacing)

        // 各グループを縦に並べたグループ
        let subitems = [largeItemLeftGroup, twoThreeItemGroup, largeItemRightGroup, twoThreeItemGroup]
        let groupsSpaceCount = CGFloat(subitems.count - 1)
        let heightDimension = NSCollectionLayoutDimension.absolute(largeItemLength * CGFloat(subitems.count) + (itemSpacing * groupsSpaceCount))
        // MEMO: 高さの計算は後に追加するスペース分も足す
        let groups = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                         heightDimension: heightDimension),
                                                      subitems: subitems)
        groups.interItemSpacing = .fixed(itemSpacing)
        let section = NSCollectionLayoutSection(group: groups)
        section.interGroupSpacing = itemSpacing
        return section
    }
```
同様に、セクションを基にUICollectionViewCompositionalLayoutを用意してCollectionViewのレイアウトに割り当てます。

<img width="200" alt="CompositionalLayout-Instagram.gif" src="https://github.com/ddd503/Image-Resource/raw/master/CompositionalLayout-Instagram.gif">

## 3. 複数Section併用 (Netflix風)
<img width="285" alt="スクリーンショット 2020-01-19 20.58.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254298/b479e54b-52fc-a275-1883-ccb63ff3450d.png">

Appleが公式で公開しているサンプルをみた感じ、「**ヘッダーを付与する**」や「**itemを横スクロールさせる**」といった実装は、セクション(NSCollectionLayoutSection)を対象に振る舞いを指定する方法が一般的なようなので、上記レイアウトを実装する場合は、「複数のセクションを用意して実行時に渡ってくるindexPath.sectionの値に合わせて適切なセクションを返す」という流れで実装してみました。

**公式サンプル**
https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/using_collection_view_compositional_layouts_and_diffable_data_sources

```SectionType.swift
/// 縦長の長方形が１つだけのセクション
    private func verticalRectangleSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let verticalRectangleHeight = collectionViewBounds.height * 0.7
        let verticalRectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                              heightDimension: .fractionalHeight(1.0)))
        let verticalRectangleGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                                         heightDimension: .absolute(verticalRectangleHeight)),
                                                                      subitem: verticalRectangleItem,
                                                                      count: 1)
        return NSCollectionLayoutSection(group: verticalRectangleGroup)
    }

/// 縦長の長方形が横スクロールするセクション（ヘッダー付き）
    private func rectangleHorizonContinuousWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let headerHeight = CGFloat(50)
        let headerElementKind = "header-element-kind"
        let insetSpacing = CGFloat(5)
        let rectangleItemWidth = collectionViewBounds.width * 0.9 / 3
        let rectangleItemHeight = rectangleItemWidth * (4/3)
        let rectangleItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                      heightDimension: .fractionalHeight(1.0)))
        let horizonRectangleGroup = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(rectangleItemWidth),
                                                                                                          heightDimension: .absolute(rectangleItemHeight)),
                                                                       subitem: rectangleItem,
                                                                       count: 1)
        horizonRectangleGroup.contentInsets = NSDirectionalEdgeInsets(top: insetSpacing, leading: insetSpacing, bottom: insetSpacing, trailing: insetSpacing)
        let horizonRectangleContinuousSection = NSCollectionLayoutSection(group: horizonRectangleGroup)
        let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(headerHeight)),
            elementKind: headerElementKind,
            alignment: .top)
        sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
        horizonRectangleContinuousSection.boundarySupplementaryItems = [sectionHeaderItem] // セクションに対してヘッダーを付与
        horizonRectangleContinuousSection.orthogonalScrollingBehavior = .continuous // セクションに対して横スクロール属性を付与
        horizonRectangleContinuousSection.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: insetSpacing, bottom: 0, trailing: insetSpacing)
        return horizonRectangleContinuousSection
    }

/// 正方形が1つだけのセクション（ヘッダー付き）
    private func squareWithHeaderSection(collectionViewBounds: CGRect) -> NSCollectionLayoutSection {
        let itemLength = collectionViewBounds.width
        let headerHeight = CGFloat(50)
        let headerInsetSpacing = CGFloat(10)
        let headerElementKind = "header-element-kind"
        let squareItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
        let squareGroup = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                                                              heightDimension: .absolute(itemLength)),
                                                           subitem: squareItem,
                                                           count: 1)
        let squareSection = NSCollectionLayoutSection(group: squareGroup)
        let sectionHeaderItem = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(headerHeight)),
            elementKind: headerElementKind,
            alignment: .top)
        sectionHeaderItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: headerInsetSpacing, bottom: headerInsetSpacing, trailing: 0)
        squareSection.boundarySupplementaryItems = [sectionHeaderItem]
        return squareSection
    }
```

用意した3種類のセクションをindexPath.sectionに合わせて返します。
※ CollectionView側のセクション数は別途指定しています。(今回は4)

各セクションをindexPath.sectionに合わせて返す場合は、UICollectionViewCompositionalLayoutのinit時に以下のイニシャライザを使います。

```UICollectionViewCompositionalLayout.h

 public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
 public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
```

実装例

```
let layout = UICollectionViewCompositionalLayout { (section, _) -> NSCollectionLayoutSection? in
            let layoutSection = 渡ってきたsectionに応じたNSCollectionLayoutSectionを返す
            return layoutSection
        }
return layout
```

上記の流れで、用意した3種類のNSCollectionLayoutSectionを4つのindexPath.sectionに対してそれぞれ適したものを返すことで、レイアウトを組みます。
今回のindexPath.sectionに対するNSCollectionLayoutSectionの順は以下。

0: 縦長の長方形が１つだけのNSCollectionLayoutSection
1: 縦長の長方形が横スクロールするNSCollectionLayoutSection（ヘッダー付き）
2: 縦長の長方形が横スクロールするNSCollectionLayoutSection（ヘッダー付き）
3: 正方形が1つだけのNSCollectionLayoutSection（ヘッダー付き）

※ 「縦長の長方形が１つだけのセクション」は、Netflix風を意識して、他のセルと違うセルクラスを使用しています）

<img width="200" alt="CompositionalLayout-Netflix.gif" src="https://github.com/ddd503/Image-Resource/raw/master/CompositionalLayout-Netflix.gif">

# ソースコード
今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/CompositionalLayouts-Sample