12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-07-23

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

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

DiffableDataSourceめっちゃ良い

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

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

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

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

イメージ.gif
↑セル全体は特に変化せず、セル内のコンテンツ(ハートマークと投稿時刻)だけが変化していますよね。

DiffableDataSourceを使って深く考えずに実装すると、いいねが押されたセル全体が一瞬ハイライトして、表示が切り替わるような見た目になります。
イメージ 2.gif
(セル全体が変化していることが変わりやすいように、セルのインスタンスごとに別の背景色を設定しています)

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

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

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
修正前後で、セルの背景が変わっていない(=インスタンスが変化していない)ことがわかるかと思います。

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

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

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

    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)
    }

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

全体ソース

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

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

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

12
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?