What is NSDiffableDataSourceSnapshot `reloadItems` for? What is NSDiffableDataSourceSnapshot `reloadItems` for? ios ios

What is NSDiffableDataSourceSnapshot `reloadItems` for?

(I've filed a bug on the behavior demonstrated in the question, because I don't think it's good behavior. But, as things stand, I think I can provide a guess as to what the idea is intended to be.)

When you tell a snapshot to reload a certain item, it does not read in the data of the item you supply! It simply looks at the item, as a way of identifying what item, already in the data source, you are asking to reload.

(So, if the item you supply is Equatable to but not 100% identical to the item already in the data source, the "difference" between the item you supply and the item already in the data source will not matter at all; the data source will never be told that anything is different.)

When you then apply that snapshot to the data source, the data source tells the table view to reload the corresponding cell. This results in the data source's cell provider function being called again.

OK, so the data source's cell provider function is called, with the usual three parameters — the table view, the index path, and the data from the data source. But we've just said that the data from the data source has not changed. So what is the point of reloading at all?

The answer is, apparently, that the cell provider function is expected to look elsewhere to get (at least some of) the new data to be displayed in the newly dequeued cell. You are expected to have some sort of "backing store" that the cell provider looks at. For example, you might be maintaining a dictionary where the key is the cell identifier type and the value is the extra information that might be reloaded.

This must be legal, because by definition the cell identifier type is Hashable and can therefore serve as a dictionary key, and moreover the cell identifiers must be unique within the data, or the data source would reject the data (by crashing). And the lookup will be instant, because this is a dictionary.

Here's a complete working example you can just copy and paste right into a project. The table portrays three names along with a star that the user can tap to make star be filled or empty, indicating favorite or not-favorite. The names are stored in the diffable data source, but the favorite status is stored in the external backing store.

extension UIResponder {    func next<T:UIResponder>(ofType: T.Type) -> T? {        let r = self.next        if let r = r as? T ?? r?.next(ofType: T.self) {            return r        } else {            return nil        }    }}class TableViewController: UITableViewController {    var backingStore = [String:Bool]()    var datasource : UITableViewDiffableDataSource<String,String>!    override func viewDidLoad() {        super.viewDidLoad()        let cellID = "cell"        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellID)        self.datasource = UITableViewDiffableDataSource<String,String>(tableView:self.tableView) {            tableView, indexPath, name in            let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath)            var config = cell.defaultContentConfiguration()            config.text = name            cell.contentConfiguration = config            var accImageView = cell.accessoryView as? UIImageView            if accImageView == nil {                let iv = UIImageView()                iv.isUserInteractionEnabled = true                let tap = UITapGestureRecognizer(target: self, action: #selector(self.starTapped))                iv.addGestureRecognizer(tap)                cell.accessoryView = iv                accImageView = iv            }            let starred = self.backingStore[name, default:false]            accImageView?.image = UIImage(systemName: starred ? "star.fill" : "star")            accImageView?.sizeToFit()            return cell        }        var snap = NSDiffableDataSourceSnapshot<String,String>()        snap.appendSections(["Dummy"])        let names = ["Manny", "Moe", "Jack"]        snap.appendItems(names)        self.datasource.apply(snap, animatingDifferences: false)        names.forEach {            self.backingStore[$0] = false        }    }    @objc func starTapped(_ gr:UIGestureRecognizer) {        guard let cell = gr.view?.next(ofType: UITableViewCell.self) else {return}        guard let ip = self.tableView.indexPath(for: cell) else {return}        guard let name = self.datasource.itemIdentifier(for: ip) else {return}        guard let isFavorite = self.backingStore[name] else {return}        self.backingStore[name] = !isFavorite        var snap = self.datasource.snapshot()        snap.reloadItems([name])        self.datasource.apply(snap, animatingDifferences: false)    }}

Based on your new example code, I agree, it looks like a bug. When you add a reloadItems to a snapshot it correctly triggers the datasource closure to request an updated cell, but the IdentifierType item that is passed to the closure is the original, not the new value that was provided with the reloadItems call.

If I changed your UniBool struct to a class so that it is a reference rather than a value type, then things worked as expected (since there is now a single instance of a UniBool rather than a new one with the same identifier).

It seems at the moment there are a couple of possible work-arounds:

  1. Use a reference rather than a value type for the IdentifierType
  2. Use an additional backing store, such as an array, and access it via indexPath in the datasource closure.

I don't think that either of these are ideal.

Interestingly, after I changed UniBool to a class, I tried creating a new instance of UniBool that had the same uuid as the existing instance and reloading that; The code crashed with an exception stating Invalid item identifier specified for reload; This doesn't sound right to me; Only the hashValue should matter, not the actual object reference. Both the original and the new objects had the same hashValue and == returned true.

Original answer

reloadItems works, but there are two important points:

  1. You must start with the datasource's current snapshot and call reloadItems on that. You can't create a new snapshot.

  2. You can't rely on the item passed to the CellProvider closure for anything other than the identifier - It doesn't represent the most recent data from your backing model (array).

Point 2 means that you need to use the provided indexPath or item.id to obtain your updated object from your model.

I created a simple example that displays the current time in a table row; This is the data source struct:

struct RowData: Hashable {    var id: UUID = UUID()    var name: String    private let possibleColors: [UIColor] = [.yellow,.orange,.cyan]    var timeStamp = Date()        func hash(into hasher: inout Hasher) {        hasher.combine(self.id)    }        static func ==(lhs: RowData, rhs: RowData) -> Bool {        return lhs.id == rhs.id    }}

Note that despite the hash function only using the id property it is also necessary to override == or you will get a crash with an invalid identifier when you attempt to reload the row.

Each second a random selection of rows are reloaded. When you run the code you see that the time is updated on those randomly selected rows.

This is the code that uses reloadItems:

self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in    guard let datasource = self.tableview.dataSource as? UITableViewDiffableDataSource<Section,RowData> else {        return    }    var snapshot = datasource.snapshot()    var rowIdentifers = Set<RowData>()    for _ in 0...Int.random(in: 1...self.arrItems.count) {        let randomIndex = Int.random(in: 0...self.arrItems.count-1)        self.arrItems[randomIndex].timeStamp = Date()        rowIdentifers.insert(self.arrItems[randomIndex])    }    snapshot.reloadItems(Array(rowIdentifers))    datasource.apply(snapshot)}

I posted the same question, not realising. I got this working by firstly converting my model to classes. Then calling 'applySnapshot' after calling 'reloadItems'.

func toggleSelectedStateForItem(at indexPath: IndexPath, animate: Bool = true) {    let item = dataSource.itemIdentifier(for: indexPath)!    var snapshot = dataSource.snapshot()    item.isSelected = !item.isSelected    snapshot.reloadItems([item])    dataSource.apply(snapshot)}