LoginSignup
0

posted at

updated at

UICollectionViewDiffableDataSourceの挙動を理解する

概要

Appleはここ2,3年、UITableViewからUICollectionViewへの置き換えを進めていて、自分も今までだったらUITableViewを使うような画面も最近はUICollectionViewでリスト表示を実装するようにしています。
iPhoneならリスト表示、iPadならグリッド表示みたいな画面を構成する場合もよくあると思うので、UICollectionViewで作っておく方が何かとスケールしやすいので、そのうちUITableViewはUICollectionViewに完全置換されると思っています。
それに合わせるように出てきたUICollectionViewDiffableDataSourceを使って実装しているのですが差分更新の挙動をしっかり理解する為に色々試してみました。
(UITableViewDiffableDataSourceもありますが、ほぼ同じです)

開発環境

  • Xcode 14.0
  • iOS16.0

サンプルコード

モックデータでToDo一覧を表示し、タップすると完了状態がトグルするアプリ
挙動を確認する為なので、アーキテクチャも適用せずVCにベタ書きします。

データエンティティ

DiffableDataSouceのItemに設定するためにHashableに準拠。
ただ、この構成だと安全な一意性に欠けるので後述の方法で修正します。

struct Todo: Hashable {
    let text: String
    var isDone: Bool

    static var sampleData: [Todo] {
        [Int](0...10).map {
            Todo(text: "ToDo \($0)", isDone: Bool.random())
        }
    }
}

ViewController (CompositionalLayout + DiffableDataSource)


class ViewController: UIViewController {
    @IBOutlet private var collectionView: UICollectionView!

    private enum Section {
        case todos
    }
    private var dataSource: UICollectionViewDiffableDataSource<Section, Todo>?
    private var todos = Todo.sampleData

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.collectionViewLayout = createLayout()
        collectionView.delegate = self
        configureDataSource()
        applySnapshot()
    }

    private func createLayout() -> UICollectionViewCompositionalLayout {
        UICollectionViewCompositionalLayout {
            .list(using: .init(appearance: .plain), layoutEnvironment: $1)
        }
    }

    private func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Todo> {
            var content = $0.defaultContentConfiguration()
            content.text = $2.text
            content.imageProperties.tintColor = $2.isDone ? .green : .secondaryLabel
            content.image = UIImage(systemName: $2.isDone ? "checkmark.circle" : "circle")
            $0.contentConfiguration = content
        }
        dataSource = UICollectionViewDiffableDataSource<Section, Todo>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Todo) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
        }
    }

    private func applySnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Todo>()
        snapshot.appendSections([.todos])
        snapshot.appendItems(todos)
        dataSource?.apply(snapshot, animatingDifferences: true)
    }
}

extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.deselectItem(at: indexPath, animated: true)
        todos[indexPath.row].isDone.toggle()
        applySnapshot()
    }
}

DiffableDataSourceの挙動を確認していく

試しにToDoを全部同じ名前にしてどうなるか試してみましょう。


    // 全部`ToDo`という名前にする
    statc var sampleData: [Todo] {
-        [Int](0...10).map {
-             Todo(text: "ToDo \($0)", isDone: Bool.random())
+        [Int](0...10).map { _ in
+            Todo(text: "ToDo", isDone: Bool.random())
        }
    }

Fatal: supplied item identifiers are not unique. Duplicate identifiers:
とのエラーがでて実行時エラーになります。

これは同じ名前、同じ完了状態のToDo出現すると一意にならなくなり特定できないので、まぁ当たり前ですよね。
IndexPathを絡めてUIKitが自動でユニーク判別してくれるかなと期待したのですが、そういった機能はDiffableDataSourceには無いようですね。

データの一意性を高める

HashableEquatableに準拠しているので比較できなければならないのですが、Hashableに自動準拠させた場合は全プロパティが同じ場合は同じデータとして認識されてしまうので、データのユニークネスを高めましょう。(比較関数をオーバライドしない場合)

SwiftUIでよくやるIdenfiableパターン

struct Todo: Hashable {
    let text: String
    var isDone: Bool
+   let id: UUID = UUID()

    static var sampleData: [Todo] {
        [Int](0...10).map { _ in
            Todo(text: "ToDo", isDone: Bool.random())
        }
    }
}

SwiftUIとかでよくやるパターンですね。そうすることで同じ名前のToDoでもDiffableDataSouceが1行ずつユニークなデータとして認識できるようになります。(敢えてIdentifiableには準拠させてません)
DBやAPIからデータを取ってくる場合は個別のIDが取れると思うので、そちらをプロパティにとり、ユニーク性を担保するのが良いでしょう。

別にこうでも良いっちゃ良い

あんまり推奨はしませんが、Indexでユニークにする方法。
データが比較可能かつユニークにさえなればいいので、Identifiableっぽく実装しなければならないわけではありません。

struct Todo: Hashable {
    let text: String
    var isDone: Bool
+   let index: Int

    static var sampleData: [Todo] {
        [Int](0...10).map { index in
-           Todo(text: "ToDo", isDone: Bool.random())
+           Todo(text: "ToDo", isDone: Bool.random(), index: index)
        }
    }
}

IDだけで一意に識別する場合の注意点

データエンティティをIdentifiableにしてUICollectionViewDiffableDataSource<Section, Todo.ID>NSDiffableDataSourceSnapshot<Section, Todo.ID>のように記述し、IDでデータを取り出してセルに表示させる方式もコンパイラが通るので&初回の表示はできるのですが、少し注意が必要です。

- UICollectionViewDiffableDataSource<Section, Todo>
+ UICollectionViewDiffableDataSource<Section, Todo.ID>

IDだけだとisDoneのプロパティを更新してSnapshotをapply()してもUIが更新されません
DiffableDataSouceの特性上、Hashableで比較されているプロパティが変わっていない場合diff無しとして判定されてUIの書き換えが行われないわけですね。(UI更新のコストを最小限に自動計算してくれている)
もしIDの配列でデータを管理したい場合は、特殊なやり方としてUUIDも新たに発行して代入すれば、Diffありとして判定されるのでUIが更新されます。

追記

  • 今回はDataSourceに新たなsnapshotを詰め直してapply()して更新していますが、reloadItems()reconfigureItems()などを使用して更新する場合はID管理にした方がいいようですね。(巨大なデータオブジェクトになった場合の比較コストなどを考慮して選定した方がいいようです)

DiffableなUIとしてのデータ定義

DiffableDataSourceが出る前のUICollectionViewDataSourceでの実装時はすべてletで構成したStruct(varの使用は最低限にしたいモチベーション)を配列で管理し、配列を洗い替えしてreloadData()したり、一部置換してreloadSections()で更新していたりしたんですが、DiffableDataSourceを使う場合は少しマインドチェンジが必要な気がしています。

UI上に渡すデータにvarを定義するのに抵抗感があるかもしれませんが、「アプリやサービスのデータ」と「DiffableなUIデータ」は切り分けて考えて、UI上で更新可能性があるデータはvarとして定義し、DiffableDataSourceに自動で差分検知をしてもらうのがベターな気がしています。
この辺りの考え方はSwiftUIのような宣言的UIフレームワークにも通ずると思っていて、自動でやってもらう領域を熟考して設計するのがこれからは大事になってきているのだと思います。

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
What you can do with signing up
0