DiffableDataSourceで個別のセルを（Delete-Insertではなく）更新する

こんにちは。 @zrn-ns です。

皆さん、DiffableDataSourceは使っていますか？
Xcode11(iOS13)から使える機能なので、そろそろ導入できるところも増えてくる頃じゃないでしょうか。

# DiffableDataSourceめっちゃ良い

`DiffableDataSource`では、セルのコンテンツ、コンテンツに対応するセル、そのレイアウトなどを宣言的に記述することができ、これまでの`DataSource　Protocol`を実装する方式よりもかなり実装が簡潔になります。

`NSDiffableDataSourceSnapshot`を使用すれば、これまでかなり面倒だったセルの移動や追加/削除などのアニメーションも、完成形のデータを示すだけで自動で行ってくれます。

# DiffableDataSourceでセルの更新を行うには？

しかしDiffableDataSourceを使い始めてからしばらくして、**セルの更新はどうすればいいのか** という問題にぶち当たりました。
Twitterで例えると、投稿のセル内のいいねボタンをタップしたとき、セル内のいいねボタンだけがアニメーションしながら更新されるような挙動のことです。

![イメージ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/50e49efe-5727-7451-cdc0-d5d1b5e3c527.gif)
↑セル全体は特に変化せず、セル内のコンテンツ（ハートマークと投稿時刻）だけが変化していますよね。

DiffableDataSourceを使って深く考えずに実装すると、いいねが押されたセル全体が一瞬ハイライトして、表示が切り替わるような見た目になります。
![イメージ 2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/f64634dd-1a75-36b5-fdf2-0c57ab51a348.gif)
（セル全体が変化していることが変わりやすいように、セルのインスタンスごとに別の背景色を設定しています）

なぜこのような挙動になるのかと言えば、DiffableDataSourceではItemの同一性をHashableで判定し、セルの移動や挿入や削除を判定しています。
Hashableのデフォルト実装はすべてのプロパティが考慮されるので、いいねの状態を切り替えただけでもItemのハッシュ値が変化し、結果DiffableDataSource上はデータの削除→追加が行われたと解釈されてしまうわけですね。

色々試行錯誤したのですが、Itemはセルの同一性を判定させるためだけに使う、という方法に行き着きました。
（あまり関係ない部分は省いています）

```swift
struct Fruit: Equatable {
    let id: UUID
    var name: String
    var isFavorite: Bool

    init(id: UUID, name: String, isFavorite: Bool) {
        self.id = id
        self.name = name
        self.isFavorite = isFavorite
    }

    mutating func toggleFavorite() {
        isFavorite = !isFavorite
    }
}

class ViewController: UIViewController {

    // 表示用のフルーツ一覧（更新されたらリストを更新する）
    var fruits: [Fruit] = [] {
        didSet {
            updateDataSource()
        }
    }

    private enum Section: Hashable {
        case `default`
    }

    // Itemにはidの情報だけを持つ。Fruitの実体はfruitsプロパティに保持している
    private struct Item: Hashable {
        let fruitId: UUID
    }

    // fruitsがSingle Source of Truthになっていて、itemsはあくまでそのインデックス情報だけを返す
    private var items: [Item] {
        fruits.sorted(by: { $0.name < $1.name })
            .map { Item(fruitId: $0.id) }
    }

    /// CollectionViewのデータソースを更新する
    private func updateDataSource() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.default])
        snapshot.appendItems(items)

        collectionViewDataSource.apply(snapshot, animatingDifferences: true)

        // ↑通常のスナップショットの更新後、↓各セルの中身を更新する
        items.forEach { reloadContentsOfItem(item: $0) }
    }

    private lazy var collectionViewDataSource: UICollectionViewDiffableDataSource<Section, Item> = .init(collectionView: collectionView) { [weak self] (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
        // 〜〜省略〜〜
    }

    // セルのインスタンスは更新せず、セルの中身だけ更新する
    private func reloadContentsOfItem(item: Item) {
        guard let indexPath = collectionViewDataSource.indexPath(for: item),
              let cell = collectionView.cellForItem(at: indexPath) as? FruitCell,
              let fruit = fruits.first(where: { $0.id == item.fruitId }) else { return }
        cell.viewModel = .init(name: fruit.name,
                               isFavorite: fruit.isFavorite)
    }
}
```

挙動はこんな感じになります。
![イメージ-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/062fff0a-e126-0f35-93bb-81ae462d9ca9.gif)
修正前後で、セルの背景が変わっていない（＝インスタンスが変化していない）ことがわかるかと思います。

コードの重要な箇所をピックアップすると、

```swift
    private var items: [Item] {
        fruits.sorted(by: { $0.name < $1.name })
            .map { Item(fruitId: $0.id) }
    }
```

`items:[Item]` はIDのみを保持しているため、ID以外の要素が変化したとしても、セルは更新されません。
その代わりに、

```swift
    private func reloadContentsOfItem(item: Item) {
        guard let indexPath = collectionViewDataSource.indexPath(for: item),
              let cell = collectionView.cellForItem(at: indexPath) as? FruitCell,
              let fruit = fruits.first(where: { $0.id == item.fruitId }) else { return }
        cell.viewModel = .init(name: fruit.name,
                               isFavorite: fruit.isFavorite)
    }
```

セル内のコンテンツを更新するための専用のメソッドを定義し、そちらでセルの更新を行います。
こうすることで、セルのインスタンスを変化させずに、表示のみを変化させる事ができるようになりました。

# 全体ソース

かなり説明不足感があると思いますので、全体のソースコードを載せておきます。
実際に動かしていただき、ソースを読んでみていただくことをおすすめします。

https://github.com/zrn-ns/DiffableDataSourceReloadCellContents


# 全体ソースに残された問題

現状のコードでは、`reloadContentsOfItem` がかなり大量に呼び出されるので、セルの数がそこそこ多くなったときにパフォーマンス面での不安が残ります。
`reloadContentsOfItem` は表示されているセルのみに対して呼び出すなど、少し工夫したほうが良いかもしれません。