LoginSignup
40
26

More than 3 years have passed since last update.

Diffable Data Sourceでセルをリロードする方法

Posted at

Diffable Data Sourceとセルのリロード

iOS 13以降、UITableViewやUICollectionViewで使えるDiffable Data Sourceですが、WWDCのセッションやAppleのサンプルを参考に実装すると、あるセルの内容を更新したいときにうまくいかない場合があります。

ここではその問題を回避する方法をいくつか紹介します。

なお、Diffable Data Sourceそのものについては、以下を見てもらうのがわかりやすいかと思います。

うまくいかないパターン

SnapshotItemIdentifierTypeHashable でさえあればなんでも構いません。WWDCのセッションやそこで参照されているサンプルでは、セルに表示するデータも含めたアイテムのstructを作ってそれを ItemIdentifierType としています。そうすることで、セルを構築する際に必要なものがそこに揃っているので便利です。

話を単純にするため、UITableViewにユーザー名を表示するという例で説明します。

Screenshot

今回の例では、ユーザーは整数の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は、次のような感じです。
セルを構築する際に、そこに表示するユーザー名として Username がそのまま使えるので便利ですね。

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のままになります :frowning2:

これを解決していきましょう。

解決策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でのみ比較するように戻して、ボタンが押された時の処理を次のようにしてみます。すると…ちっとも更新されません :cry:

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 の中身を更新することができます。(userlet になっていて変更してないことに注目)

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

40
26
1

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
40
26