reloadItems behavior on NSDiffableDataSourceSnapshot

Hi everyone,


I cannot figure out how to use reloadItems on a diffable data source snapshot. What I'm trying to do is to change an item and reload it in order to update its corresponding collection view cell. Everything seems to work fine (the snapshot is applied and the cell is reloaded) but the cell keeps its old values. After some investigation I noticed that when using a struct as the item, reloadItems doesn't change the snapshot with the updated values. If I use a class instead, it works as expected. Is this the normal behavior?


Look at these 2 examples:

- https://gist.github.com/emiliopavia/f9c8a1ee2aa41fc314bfa7e8f9125bb9

- https://gist.github.com/emiliopavia/d3a0cdaef648fb782302054d95e5ae9d


The only difference is class vs. struct.


Thanks!

Yeah as far as I can tell, reloadItems is either completely broken or it does not do what we would intuitively think it does. Even in your example, reload doesnt actually work with the class...its just that you are changing a property on a reference object, reload is irrelevant at that point.

This is definitely something that should have better documentation.
This is the only way I could get it to work. In my case I am receiving an update from my app server that includes everything that should be displayed in the list. Simply applying the snapshot didn't cause the cells that remained in place to update.

By dispatching a reload call on the main queue, I was able to successfully get those cells to reload their views.
Code Block swift
fileprivate func updateUI(notificationsSnapshot snapshot: NotificationsController.NotificationsSnapshot) {
    currentSnapshot = .init()
    currentSnapshot.appendSections([.main])
    currentSnapshot.appendItems(snapshot.notifications)
    dataSource.apply(currentSnapshot, animatingDifferences: snapshot.animatesChanges)
    if !snapshot.updatedNotifications.isEmpty {
        DispatchQueue.main.async {
            self.currentSnapshot.reloadItems(snapshot.updatedNotifications)
            self.dataSource.apply(self.currentSnapshot, animatingDifferences: snapshot.animatesChanges)
        }
    }
}


I think reloadSections/reloadItems just calculate the items need to be reload and set the tag, it does not change the truth datasource array. The reload effect actually occurs when the dataSource's apply() call. It's behaviour may be like setNeedsLayout.
I can only think of this reason to explain its current behavior.
The misconception here seems to be that diffable data source is not a data store. It only cares about identifiers and it considers identifiers to be equal as long as their isEqual: methods return YES. Diffable data source is an identifier based mapping between your data store and the index path based nature of UICollectionView and UITableView.

reloadItems() makes sure that the index path representing that item is reloaded via a reloadItemsAtIndexPaths call in the update.

If you have mutable or complex data objects, you should not use them as item identifiers directly but rather just use your objects' identifiers with diffable data source and then fetch the correct object from your data store when configuring the cell by referencing the identifier.

So in your gist for example, you should store the UUID identifier of your item in the data source instead of the whole object.
This definitely feels like a bug to me. The DiffableDataSource is passing along the stale version of the model object to the cellProvider (from the previous snapshot rather than the new one, which you can see by reloading again and you'll get the new object). I've written a backport of diffable data sources that uses the built in API on iOS 13+ and a custom implementation otherwise, and the bug doesn't exist in my custom implementation.

Diffable data sources were advertised to support using custom model objects rather than just identifiers, so to be told that we shouldn't be using it this way is a bit ridiculous.

From WWDC 2019:

For our mountain type, we'll look at our Mountains Controller, which is again, our model layer. And here, we see that we've declared mountain as a Swift struct. And we declared that struct type as hashable so that we can use it with DiffableDataSource natively rather than explicitly have to pass an identifier. And the important requirement there is just that each mountain be uniquely identifiable by its hash value. So we achieve this by giving each mountain an automatically generated unique identifier.

Playground code to reproduce the second reload behaviour:

Code Block
struct Model: Hashable {
    var id: UUID
    var name: String
    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    static func == (lhs: Model, rhs: Model) -> Bool {
        lhs.id == rhs.id
    }
}
let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 300, height: 500), style: .grouped)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
let dataSource = UITableViewDiffableDataSource<Int, Model>(tableView: tableView) { tableView, indexPath, model in
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    cell.textLabel?.text = model.name
    return cell
}
var model = Model(name: "A Model")
var snapshot = NSDiffableDataSourceSnapshot<Int, Model>()
snapshot.appendSections([0])
snapshot.appendItems([model])
dataSource.apply(snapshot, animatingDifferences: false)
_ = tableView // Shows "A Model"
model.name = "Another Name"
var secondSnapshot = NSDiffableDataSourceSnapshot<Int, Model>()
secondSnapshot.appendSections([0])
secondSnapshot.appendItems([model])
secondSnapshot.reloadItems([model])
dataSource.apply(secondSnapshot, animatingDifferences: true)
_ = tableView // Shows "A Model"
var thirdSnapshot = NSDiffableDataSourceSnapshot<Int, Model>()
thirdSnapshot.appendSections([0])
thirdSnapshot.appendItems([model])
thirdSnapshot.reloadItems([model])
dataSource.apply(thirdSnapshot, animatingDifferences: true)
_ = tableView // Shows "Another Name"

My understanding was, that identifiers must be Equatable, and Hashable.
Hashable - identifies the item: if two items have the same hashValue, then it is the same item.
Equatable - used for diffing purposes, whether the item is added, deleted or updated: if two items are equal, then no update (reload) is needed.

Unfortunately, my understanding is in contradiction to an Apple Engineer saying "It only cares about identifiers and it considers identifiers to be equal as long as their isEqual: methods return YES."

As I found recently, you just need to add other properties of the model structure to the Equatable protocol function in case you want your own ==. After that, data source can figure out the difference and will update item correctly.

Code Block
struct MyItem: Hashable {
let identifier = UUID().uuidString // We just need an ID, right?
var value: String?
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
/* Optional solution here, in case you don’t want to check all properties for equality, or if any of your properties are not also Equatable. In other words, you don't need to overload it and leave default implementation */
static func == (lhs: MyItem, rhs: MyItem) -> Bool {
return lhs.identifier == rhs.identifier && lhs.value == rhs.value // Boom! It just works ;-)
}
}

1. Option

Quote from Apple Engineer

If you have mutable or complex data objects, you should not use them as item identifiers directly but rather just use your objects' identifiers with diffable data source and then fetch the correct object from your data store when configuring the cell by referencing the identifier.

So in your gist for example, you should store the UUID identifier of your item in the data source instead of the whole object.

This will work, but it requires more work and bookkeeping and is arguably less efficient.


2. Option

Adjust your identifier (model) such that hash and == changes whenever your displayed content changes
Code Block Swift
struct MyItem: Hashable {
let identifier = UUID()
var value: String?
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
hasher.combine(value)
}
static func == (lhs: MyItem, rhs: MyItem) -> Bool {
lhs.identifier == rhs.identifier && value == value
}
}

Many people recommended this approach, but it has the following caveat: when value changes, the model becomes a different identifier for the diffable data source and thus a simultaneous move of the item is decomposed into a delete + insert, which causes a visual bug, but nothing more.


3. Option - My Solution

Keep your model as is. First apply the snapshot as usual, then apply the snapshot with animatingDifferences: false in the completion handler.
Code Block Swift
dataSource.apply(snapshot, animatingDifferences: true, completion: {
dataSource.apply(snapshot, animatingDifferences: false)
})


The bug seems to be that on apply with animatingDifferences: true, diffable data source updates the configured cells before internally updating the identifiers that stayed "equal" according to ==. Note: In the completion handler one could also call collectionView.reloadData(), or anything else that forces the configured cells to be updated.

The only way I could fix my issue was to call myCollectionView.reloadData() and then call Apply!!! I had a segmented control and I am switching the data when I switch segments.

DispatchQueue.main.async {  [weak self] in self?.collectionView?.reloadData(). //<------ call this!!! and then call apply

self?.datasource?.apply(snapshot, animatingDifferences: false, completion: { [weak self] in
reloadItems behavior on NSDiffableDataSourceSnapshot
 
 
Q