Diffable Data Sourceとセルのリロード
iOS 13以降、UITableViewやUICollectionViewで使えるDiffable Data Sourceですが、WWDCのセッションやAppleのサンプルを参考に実装すると、あるセルの内容を更新したいときにうまくいかない場合があります。
ここではその問題を回避する方法をいくつか紹介します。
なお、Diffable Data Sourceそのものについては、以下を見てもらうのがわかりやすいかと思います。
- WWDC 2019: Advances in UI Data Sources
- 時代の変化に応じて進化するCollectionView ~Compositional LayoutsとDiffable Data Sources~
うまくいかないパターン
Snapshot の ItemIdentifierType
は Hashable
でさえあればなんでも構いません。WWDCのセッションやそこで参照されているサンプルでは、セルに表示するデータも含めたアイテムのstructを作ってそれを ItemIdentifierType
としています。そうすることで、セルを構築する際に必要なものがそこに揃っているので便利です。
話を単純にするため、UITableViewにユーザー名を表示するという例で説明します。

今回の例では、ユーザーは整数のIDとユーザー名で構成されるものとして、次のようにstructを定義します。
struct User: Hashable {
let id: Int
let name: String
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
Snapshotはこんな感じで構築します。
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.first])
snapshot.appendItems([
User(id: 1, name: "Henry"),
User(id: 2, name: "Thomas"),
User(id: 3, name: "Percy"),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: false)
このSnapshotを適用しているData Sourceは、次のような感じです。
セルを構築する際に、そこに表示するユーザー名として User
の name
がそのまま使えるので便利ですね。
dataSource = UITableViewDiffableDataSource(tableView: tableView,
cellProvider: { (tableView, indexPath, user) in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = user.name
return cell
})
さて、ここでボタンが押されると、次のSnapshotを適用してみるようにします。
要は2番のidを持つユーザーの名前を変更しています。
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.first])
snapshot.appendItems([
User(id: 1, name: "Henry"),
User(id: 2, name: "Tommy"), // be modified
User(id: 3, name: "Percy"),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: true)
期待される動作は、Thomasと表示されていた2行目がTommyに変更されることですが、これはうまくいきません。Thomasのままになります
これを解決していきましょう。
解決策1: idだけで同値性を判定しない
さて、なぜ期待通りに動作しないのでしょうか。
それは、 User
の同値性はidのみで比較されているからです。
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
この同値性の評価においては、 name
は存在しないと考えても同じです。
User(id: 2, name: "Thomas") == User(id: 2, name: "Tommy")
を、 User(id: 2) == User(id: 2)
と考えればいいでしょう。当然、 同じもの だと判定されます。同じなら更新されないのは当たり前です。
そこで、これを別物として扱ってやれば、更新はされるようになります。
struct User: Hashable {
let id: Int
let name: String
// static func == (lhs: User, rhs: User) -> Bool {
// return lhs.id == rhs.id
// }
//
// func hash(into hasher: inout Hasher) {
// hasher.combine(id)
// }
}
SwiftのstructはStoredプロパティがすべて Hashable
なら、自動でそれらをすべて比較する ==
と hash(into:)
が生成されるのでこれで構いません。
これで、 User(id: 2, name: "Thomas")
と User(id: 2, name: "Tommy")
は別物になったので、ちゃんと表示が更新されるようになります。
ただ、アニメーションさせるとわかるのですが、Thomasの行が削除されて、Tommyの行が追加されたという扱いになるので少し違和感が残ります。
解決策2: リロードさせる
実はSnapshotには、特定のアイテムをリロードさせるメソッドが用意されています。
mutating func reloadItems(_ identifiers: [ItemIdentifierType])
なんだ、最初からこれを使えばよかったのか!
そこで User
は元のidでのみ比較するように戻して、ボタンが押された時の処理を次のようにしてみます。すると…ちっとも更新されません
var snapshot = dataSource.snapshot()
snapshot.reloadItems([User(id: 2, name: "Tommy")])
dataSource.apply(snapshot, animatingDifferences: true)
なぜでしょうか。
実はリロードはされているのです。しかし、セルを構築する際に呼ばれる以下のクロージャーで user.name
がThomasのままなのです。
cellProvider: { (tableView, indexPath, user) in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = user.name // Thomas
return cell
}
落ち着いて考えてみましょう。
Snapshotの ItemIdentifierType
は名前が示すとおりアイテムの識別子となる型です。ですから、 reloadItems()
に渡すのはリロードしたいセルの識別子(の配列)です。
snapshot.reloadItems([User(id: 2, name: "Tommy")])
これは単にidが2番のものをリロードしてくれと言っているだけです(同値性の判定はidのみに戻しています)。 持っているデータを更新してくれと言っているわけではありません。
Data SourceやSnapshotに渡すのはあくまでも識別子であってデータそのものではありません。ただ、データも識別子の一部として一緒に持っておくと便利だからそうしているだけです。識別子としては変更がないのにデータを変更したいという考えが間違っているように思います。
そこで、識別子とデータを別に管理するようにしてみます。まず、識別子とデータに分けます。
※以下の例はちょっとバカっぽいですが、現実のUserには名前以外にも他にいろいろデータがあると思います。
struct UserID: Hashable {
let id: Int
}
struct User {
let name: String
}
Snapshotに渡すのは識別子、つまり UserID
です。
var snapshot = NSDiffableDataSourceSnapshot<Section, UserID>()
snapshot.appendSections([.first])
snapshot.appendItems([
UserID(id: 1),
UserID(id: 2),
UserID(id: 3),
], toSection: .first)
dataSource.apply(snapshot, animatingDifferences: false)
識別子からデータを取れるようにしましょう。
private var users: [UserID: User] = [
UserID(id: 1): User(name: "Henry"),
UserID(id: 2): User(name: "Thomas"),
UserID(id: 3): User(name: "Percy"),
]
セルを構築するときはここからデータを取得します。
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, userID) in
guard let self = self else { return nil }
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = self.users[userID]?.name
return cell
})
このようにしておけば、識別子は更新しなくてもデータは更新できます。
let userID = UserID(id: 2)
// データを更新
users[userID] = User(name: "Tommy")
// リロード要求
var snapshot = dataSource.snapshot()
snapshot.reloadItems([userID])
dataSource.apply(snapshot, animatingDifferences: true)
これで期待する動きになります。
ただ、データを別に管理しなきゃいけなくなりました。
解決策3: 識別子からデータを「参照」させる
もともとデータをどこかで管理しているのなら解決策2は素直でいいんじゃないかなと思っているのですが、最初の(うまくいかないパターンのように)識別子の方にデータを持たせたい場合もあると思います。
そこで識別子にデータを持たせるのではなく、識別子からデータを参照させましょう。識別子自体は更新せずに、識別子に参照させたものを更新するようにします。
User
のstructは次のようにします。
struct User: Hashable {
let id: Int
class Info {
var name: String
init(name: String) {
self.name = name
}
}
let info: Info
init(id: Int, info: Info) {
self.id = id
self.info = info
}
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
違いは、id以外の情報(といってもここではnameしかないですが)を Info
という class に入れて、それを参照するようにしました。 Info
はstructではダメです。classにして参照型にする必要があります。
こうすることで、次のように User.info
自体は変更せずに Info
の中身を更新することができます。(user
は let
になっていて変更してないことに注目)
var snapshot = dataSource.snapshot()
guard let user = snapshot.itemIdentifiers(inSection: .first)
.first(where: { $0.id == 2 }) else { return }
user.info.name = "Tommy"
snapshot.reloadItems([user])
dataSource.apply(snapshot, animatingDifferences: true)
まとめ
Diffable Data Sourceでセルをリロードさせたいなら次の方法が考えられます。
それぞれ長所・短所はあるので、どれが一番いいというのは一概には言えないと思います。
- 別の識別子であると認識させる(厳密にはリロードではない)
- 識別子とは別にデータを管理しておく
- 識別子からデータを「参照」させる
なお、今回の検証のために作ったプログラム全体はGitHubに置いてあります。
https://github.com/hironytic/DiffableDataSourceReload