こんにちは。 @zrn-ns です。
皆さん、DiffableDataSourceは使っていますか?
Xcode11(iOS13)から使える機能なので、そろそろ導入できるところも増えてくる頃じゃないでしょうか。
DiffableDataSourceめっちゃ良い
DiffableDataSource
では、セルのコンテンツ、コンテンツに対応するセル、そのレイアウトなどを宣言的に記述することができ、これまでのDataSource Protocol
を実装する方式よりもかなり実装が簡潔になります。
NSDiffableDataSourceSnapshot
を使用すれば、これまでかなり面倒だったセルの移動や追加/削除などのアニメーションも、完成形のデータを示すだけで自動で行ってくれます。
DiffableDataSourceでセルの更新を行うには?
しかしDiffableDataSourceを使い始めてからしばらくして、セルの更新はどうすればいいのか という問題にぶち当たりました。
Twitterで例えると、投稿のセル内のいいねボタンをタップしたとき、セル内のいいねボタンだけがアニメーションしながら更新されるような挙動のことです。
↑セル全体は特に変化せず、セル内のコンテンツ(ハートマークと投稿時刻)だけが変化していますよね。
DiffableDataSourceを使って深く考えずに実装すると、いいねが押されたセル全体が一瞬ハイライトして、表示が切り替わるような見た目になります。
(セル全体が変化していることが変わりやすいように、セルのインスタンスごとに別の背景色を設定しています)
なぜこのような挙動になるのかと言えば、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)
}
}
挙動はこんな感じになります。
修正前後で、セルの背景が変わっていない(=インスタンスが変化していない)ことがわかるかと思います。
コードの重要な箇所をピックアップすると、
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
は表示されているセルのみに対して呼び出すなど、少し工夫したほうが良いかもしれません。