DiffableDataSouceとCompositionalLayoutについて 



こんにちはmoriです。
自分の働いているスタートアップのサービスでは立ち上げ時にiOS13以上をDeploymentTargetにしていたのもあり、DiffableDataSourceとCompositionalLayoutを全面的に採用しました。
導入から１年近く経ったので今回の記事では実際に使ってみた感想や使用法について書いてみます。

# DiffableDataSourceについて
iOS13から導入されたUITableView/UICollectionViewのDataSourceです。
[DiffableDataSource](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource)はSectionとItemの２つの型を必要とします。

```swift
@MainActor class UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable
```
前者はSectionの識別に使うもので後者は実際にセルに表示したいアイテムを指します。
どちらもHashableに準拠していればよく、自分のチームでは慣れ親しんでいた[RxDataSources](https://github.com/RxSwiftCommunity/RxDataSources)に合わせて以下のような形で定義していました。

```swift
enum Section {
  case images
}
enum CellItem: Hashable {
  case image(String)
}
```
##基本的な使い方
まずはDataSourceを作ってみます。
上記のモデルを使用して宣言すると以下のような形になります。
CellItemのswitchでcellのReuseをして返してあげるだけですね。
Optionalは許されていますがnilを返すとクラッシュするのでforce wrapが減ったくらいに留めておきましょう。

```swift
private lazy var dataSource: UICollectionViewDiffableDataSource<Section, CellItem> = {
    .init(
        collectionView: collectionView
    ) {[unowned self] collectionView, indexPath, cellItem -> UICollectionViewCell? in
        switch cellItem {
        case let .image(image):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath)
            cell.configure(image)
            return cell
        }
    }
}()
```
アイテムを追加したい時はまず`NSDiffableDataSourceSnapshot`に追加して`apply`します。
この際にSectionの追加を忘れると追加する対象が分からなくてクラッシュするので注意しましょう。

```swift
var snapshot = NSDiffableDataSourceSnapshot<Section, CellItem>()
snapshot.appendSections([.images])
snapshot.appendItems(
    [
        "https://hogehoge1",
        "https://hogehoge2",
        "https://hogehoge3"
    ]
        .map(CellItem.image),
    toSection: .main
)
dataSource.apply(snapshot)
```
基本的には以上の２つだけなのでとっても簡単です。
applyについて少し説明すると`DataSource.apply(snapshot: , animatingDifferences: Bool = true, completion: (() -> Void)? = nil)`でcompletionがあります。
これはsnapshotを適応してアニメーションが終了した際に呼ばれるクロージャで、更新後に走らせたい処理をより明確に書けるようになりました。
例えば`layoutSubViews`でセルの数の変化を検知して処理を走らせるような場面の置き換えができます。

`animationDifference`についてはapply時のアニメーションの有無です。
基本的にはデフォルトのtrueでいいと思いますが、CollectionView in CollectionViewCellの場合などでapplyの頻度が多い場合はfalseにしておくことでパフォーマンスを改善することができます。

## ユニークの評価について
DiffableDataSourceではその名の通り差分更新を行うため渡すアイテムはユニークである必要があります。
ユニークで無い場合は下記のエラーで怒られます。
`Terminating app due to uncaught exception 'NSInternalInconsistencyException', 
reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers`

そもそもDiffableを使う必要があるのかという話ではありますが、画像などのユニークでないアイテムを渡したい時はどうするかというと
Appleは下記のような形でUUIDを持たせることで解決しています。

```swift
struct Image: Hashable {
  var value: String
  private var uuid = UUID().uuidString
}
```

```swift
var snapshot = NSDiffableDataSourceSnapshot<Section, CellItem>()
snapshot.appendSections([.main])
snapshot.appendItems(
  [
    "hogehoge",
    "hogehoge",
    "hogehoge"
  ]
  .map(Image.init)
  .map(CellItem.uniquedImage)
  ,
  toSection: .main
)
dataSource.apply(snapshot)
```

ここでふとHashableであるならプロパティでUUIDを持たずとも`hash(into: Hasher)`でUUIDを渡せばいいのでは？と思い下記のように変更してみました。

```swift
struct Image: Hashable {
  var value: String
  func hash(into hasher: inout Hasher) {
    hasher.combine(UUID().uuidString)
    hasher.combine(image)
  }
}
```

結果としては先ほどと同じエラーが吐かれてしまいます。
`Terminating app due to uncaught exception 'NSInternalInconsistencyException', 
reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers`
不思議ですね、hashValueの値はきちんとユニークになっています。

```
-7942012077661377080
-6205962773686266410
-5034117499723952161
```

HashableはEquatableを継承したprotocolであるので今度は`==`で同一にならないようにしてみました

```swift
struct Image: Hashable {
  var value: String
  static func == (lhs: Image, rhs: Image) -> Bool {
    false
  }
}
```

この場合はエラーは吐かれませんでした。
ということでユニークの判定はhashValueではなく==で行なっているみたいです。
推測にはなりますがSwift5.1でEquatableなコレクションに対して実装された[差分抽出](https://github.com/apple/swift/blob/a0421124f0de37d696e503c17c05afba19f74cf2/stdlib/public/core/Diffing.swift)を使用してそうですね。

# CompositionalLayoutについて

CompositionalLayoutはDiffableDataSourceと同じくiOS13で追加されたCollectionViewのレイアウトです。

Cellに相当する`NSCollectionLayoutItem`, 複数のCellを一塊とした時の`NSCollectionLayoutGroup`, 
そして`NSCollectionLayoutSection`という３つの単位で構成されていて、それぞれのパーツを組み合わせていくことで様々なレイアウトが可能になります。

また`NSCollectionLayoutSection`毎にスクロール方向が切り替え可能で途中で横スクロールに変わるといった従来ならCollectionView in CollectionViewしないといけない場面も１つのCollectionViewだけで実装できるようになっています。

ItemとGroupの`layoutSize`で使用する`widthDimension,heightDimension`は以下の４つが指定できます

- `fractionalWidth(CGFloat)`: Groupの横幅に対しての比
- `fractionalHeight(CGFloat)`: Groupの縦幅に対しての比
- `absolute(CGFloat`): 数値通りの大きさ
- `estimated(CGFloat)`: autoLayoutのcontentSize

またUICompositionalLayoutのinitは４種類あります。

- `init(section: NSCollectionLayoutSection)`
    - どのSectionでも渡したLayoutSection通りのレイアウトになります
- `init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)`
    - 上記に加えてConfigurationを渡せます。 Configurationではデフォルトのスクロール方向、セクション間のinsets、headerの設定が可能です
- `init(sectionProvider: UICollectionViewCompositionalLayoutSectionProvider)`
    - `(index: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?`のクロージャでレイアウトを返します。
    - 前者のindexはSectionの番号が渡されて、後者のEnvironmentはダークモードや画面スケールを取得することが可能です。
    - Optionalが許容されていますがnilを返すとクラッシュします。気をつけましょう
- `init(sectionProvider: UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)`
    - 上記に加えてConfigurationを渡せます

言葉で書いていてもGroupなどの概念も含めてなかなか想像ができないと思うので簡単な例をコードで示してみます。

##レイアウトの作成

- 4列グリッド

```swift
let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1/4),
        heightDimension: .fractionalHeight(1)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .fractionalWidth(1/4)
    ),
    subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
``` 
図解すると以下のような感じになります
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/e6d5776f-b986-a8c4-f88a-2bb8c9da1a37.png width=25%>

- 縦方向にestimatedSize

```swift
let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(50)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(50)
    ),
    subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
```

CompositionalLayoutでのestimatedの良い点はestimatedの方向を決められる点です。
従来ならCell側でUIScreen.main.boundsを使って親のCollectionViewがスクリーンの大きさと同じであることを強制したり、外部からCollectionViewのwidthやheightを渡すといった作業が必要になっていました。
その点、CompositionalLayoutではレイアウトを組む際にCell伸びる方向が指定できるので上記のような部分がいらなくなります。

ここまでのレイアウトはFlowLayoutなどでも特に苦労することなく実現できます。
次はFlowLayoutでは少し面倒なタグクラウドのようなレイアウトをやってみたいと思います

```swift
let item = NSCollectionLayoutItem(
    layoutSize: .init(
        widthDimension: .estimated(24),
        heightDimension: .absolute(16)
    )
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .absolute(16)
    ),
    subitems: [item]
)
group.interItemSpacing = .fixed(4)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/d4f022c2-b52c-f812-d963-0209877f443e.png width=25%>

たったこれだけで実現できました。従来だと左詰めの為だけに継承したレイアウトを作成しないといけない部分でしたが難なく実現できます。

ここまで複数のレイアウトを紹介しましたが基本となるのはスクロール方向に直交するGroupにItemを詰めていくように組んでいくことです。
もしグリッドのレイアウトでGroup.Verticalを使うと以下のようになります。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/47855943-130d-5ccc-def3-59ab04ee122f.png width=25%>
Itemは最初のGroupから順に入っていくのでIndexPathが見た目と大きくズレてしまうことや縦のGroupに入るItemの個数をViewModelなどに保持したデータから計算しないといけません。

複数のレイアウト例でFlowLayoutより簡単にさまざまなレイアウトが実現できることをお見せできたと思いますが、重要なのはこれらが簡単に再利用できることです。
最初の方に紹介した`UICollectionViewCompositionalLayout.init`の中にSectionProviderというのがあります。
これは以下のようにSectionのナンバーで分岐させることができるので
ある画面では estimated -> grid -> hashtag, 別の画面では grid -> estimated というような形のレイアウトにしたい場合でもNSCollectionLayoutSectionを作り出すメソッドを切り離しておけば使い回すだけで実現することができます。

例えば紹介した3つのレイアウトはセルの数などを参照しないのでLayoutFactoryのような形で保持してあげれば以下のように使いまわせますね。

```swift
final class LayoutFactory {
    static　func createGrid(raw: Int) -> NSCollectionLayoutSection {}
    static func createEstimatedHight() -> NSCollectionLayoutSection {}
    static func createTagCloud() -> NSCollectionLayoutSection {}
}
```

```swift
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { sectionIndex, environment -> NSCollectionLayoutSection? in
    switch sectionIndex {
    case 0:
       return LayoutFactory.createGrid(raw: 4)
    case 1:
       return LayoutFactory.createEstimatedHight()
    case 2:
       return LayoutFactory.createTagCloud()
    default:
        return nil
    }
}
```

## より複雑なレイアウトの作成
更に複雑なレイアウトを実現してみます。
例えばこのようなそれぞれのCellの高さが違うようなレイアウトを作成したいときはどうしたらいいでしょう
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/576af206-f986-3482-0076-6ff38efb4773.png width=25%>
試しに以下のようなレイアウトを組んでみましょう

```swift
var items: [NSCollectionLayoutItem] = []
for index in 0..<6 {
    let item = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(
                .init([0.3, 0.6, 0.9].randomElement() ?? 0.3)
            )
        )
    )
    items.append(item)
}
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(160)
    ),
    subitems: items
)
let section = NSCollectionLayoutSection(group: group)
collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
```
groupに対して6個のランダムな比率のitemを追加するレイアウトですね
実行してみると
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/9f729c74-018d-010c-f61a-dbd5fc6fad84.png width=25%>
残念ながら思ってたのとは違う結果になりました。
これはHorizontalなGroupはそれぞれのItemの高さに合わせられないため、以下のようなレイアウトになっているからです。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/5439ceee-c1c0-6d35-d56a-1d8d546d6a8c.png width=25%>
となると高さがバラバラなのは実現できないのかというと実現できます。
NSCollectionLayoutGroupはItemだけではなくGroupからも生成することができるのでそれぞれの列でVerticalなGroupを作成することで実現が可能です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/caf413c8-bc5f-eb72-4511-6c8e8e9932a4.png width=25%>

ということで以下のように縦方向のGroupを３つ並べるように手直ししてみると

```swift
var groups: [NSCollectionLayoutGroup] = []
for _ in 0..<3 {
    let itemHeight = [0.3, 0.5, 0.7].randomElement() ?? 0.3
    let firstItem = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(itemHeight)
        )
    )
    let secondItem = NSCollectionLayoutItem(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(1-itemHeight)
        )
    )
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: .init(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(1)
        ),
        subitems: [firstItem, secondItem]
    )
    groups.append(group)
}
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: .init(
        widthDimension: .fractionalWidth(1),
        heightDimension: .absolute(320)
    ),
    subitems: groups
)
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289843/18edc7f1-44e9-769a-1a48-8bd2908943bb.png width=25%>

空白が開かずにちゃんと詰められましたね。
GroupからGroupを作るのは複雑なレイアウトを達成する上で必須のテクニックになっているのでなんとなく記憶の片隅に留めておくと便利かもしれません。

#終わりに
ここまでCompositionalLayoutの良い部分や複雑なレイアウトの実現方法を紹介しましたが運用していく中では微妙な点もありました。
例えば、didEndScrollが取れなかったりスクロール量の変化といった部分に関しては独自のUICollectionViewLayuoutの方が優れている側面もあります。
また切り出しておくことで対処はできますが、どうしても記述量が増えてしまうのでグリッドのような簡単なレイアウトに関しては逆に面倒に感じる場面もありました。

iOS13以降ではSwiftUIが注目されがちですが、まだ知見が浅く今まで通りの開発スピードを出せるかどうか不安な時は橋渡しとして進化したUIKitを活用してみるのもいいと思います。