What is NSDiffableDataSourceSnapshot `reloadItems` for?
Asked Answered
V

5

44

I'm having difficulty finding the use of NSDiffableDataSourceSnapshot reloadItems(_:):

  • If the item I ask to reload is not equatable to an item that is already present in the data source, I crash with:

    Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempted to reload item identifier that does not exist in the snapshot: ProjectName.ClassName

  • But if the item is equatable to an item that is already present in the data source, then what's the point of "reloading" it?

You might think the answer to the second point is: well, there might be some other aspect of the item identifier object that is not part of its equatability but does reflect into the cell interface. But what I find is that that's not true; after calling reloadItems, the table view does not reflect the change.

So when I want to change an item, what I end up doing with the snapshot is an insert after the item to be replaced and then a delete of the original item. There is no snapshot replace method, which is what I was hoping reloadItems would turn out to be.

(I did a Stack Overflow search on those terms and found very little — mostly just a couple of questions that puzzled over particular uses of reloadItems, such as How to update a table cell using diffable UITableView. So I'm asking in a more generalized form, what practical use has anyone found for this method?)


Well, there's nothing like having a minimal reproducible example to play with, so here is one.

Make a plain vanilla iOS project with its template ViewController, and add this code to the ViewController.

I'll take it piece by piece. First, we have a struct that will serve as our item identifier. The UUID is the unique part, so equatability and hashability depend upon it alone:

struct UniBool : Hashable {
    let uuid : UUID
    var bool : Bool
    // equatability and hashability agree, only the UUID matters
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }
    static func ==(lhs:Self, rhs:Self) -> Bool {
        lhs.uuid == rhs.uuid
    }
}

Next, the (fake) table view and the diffable data source:

let tableView = UITableView(frame: .zero, style: .plain)
var datasource : UITableViewDiffableDataSource<String,UniBool>!
override func viewDidLoad() {
    super.viewDidLoad()
    self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    self.datasource = UITableViewDiffableDataSource<String,UniBool>(tableView: self.tableView) { tv, ip, isOn in
        let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: ip)
        return cell
    }
    var snap = NSDiffableDataSourceSnapshot<String,UniBool>()
    snap.appendSections(["Dummy"])
    snap.appendItems([UniBool(uuid: UUID(), bool: true)])
    self.datasource.apply(snap, animatingDifferences: false)
}

So there is just one UniBool in our diffable data source and its bool is true. So now set up a button to call this action method which tries to toggle the bool value by using reloadItems:

@IBAction func testReload() {
    if let unibool = self.datasource.itemIdentifier(for: IndexPath(row: 0, section: 0)) {
        var snap = self.datasource.snapshot()
        var unibool = unibool
        unibool.bool = !unibool.bool
        snap.reloadItems([unibool]) // this is the key line I'm trying to test!
        print("this object's isOn is", unibool.bool)
        print("but looking right at the snapshot, isOn is", snap.itemIdentifiers[0].bool)
        delay(0.3) {
            self.datasource.apply(snap, animatingDifferences: false)
        }
    }
}

So here's the thing. I said to reloadItems with an item whose UUID is a match, but whose bool is toggled: "this object's isON is false". But when I ask the snapshot, okay, what have you got? it tells me that its sole item identifier's bool is still true.

And that is what I'm asking about. If the snapshot is not going to pick up the new value of bool, what is reloadItems for in the first place?

Obviously I could just substitute a different UniBool, i.e. one with a different UUID. But then I cannot call reloadItems; we crash because that UniBool is not already in the data. I can work around that by calling insert followed by remove, and that is exactly how I do work around it.

But my question is: so what is reloadItems for, if not for this very thing?

Vig answered 26/9, 2020 at 19:25 Comment(13)
@Paulw11 No, you're right, but neither of those things makes any difference. It's perfectly legal to have the data live entirely in the data source. It sounds like you're implying that the only way to make a reloadable snapshot is to have a data source where the cell provider function doesn't look at the value that it is given at all — it has to look outside the data source entirely, at the backing store. But if I wanted to do that, what on earth is the diffable data source for? I could have just stuck with the old cellForRowAt implementation.Vig
Moreover, if the data source is not going to pick up the bool value, then why am I including it at all? You're saying I would keep the bools only in the external "backing store"? That seems nutty to me.Vig
And in any case you are still not explaining what happened between the two print statements: I provided a new value and said reload it, and it was not in fact reloaded. So what is reloading for? Are you saying it is so that the cell provider function will be called again so I can look at the backing store?Vig
Yes, I have deleted the comment while I think about it some more. However, my understanding is that the main benefit of the diffable data source was that it let you simply manipulate your backing store without having to worry about the sequence of insert/move/delete that often caused crashes with the old approach. You simply provide the cell you are asked for and you can add operations to the snapshot in terms of the identifiers you are adding/moving/deleting without having to worry about array indices or even section indices.Fong
I agree that it should, in theory, be possible for the data to live purely in the snapshot, in practice you would almost always have some persistence store that is providing your data and you would typically use that or its in memory representation.Fong
@Fong OK, so I have to revise my entire understanding of what a diffable data source is and what "works" means. I just don't like it. I feel this should be made to work with no backing store, and I have filed a bug on the fact that it does not.Vig
Going back to the WWDC 2019 video, I agree you should be able to use the datasource without any other backing store. I had a play around with your example code, and it definitely looks like a bug to me. If you make UniBool a class and not a struct then you get the expected behaviour. It seems like` reloadItems` does not actually take the new value from the snapshot so it works with a reference type.Fong
If we make UniBool a class, there is no need to call reloadItems in the first place, so that doesn't really prove as much as one might have hoped. I expect I'll be told that you are supposed to use a back store and that this "works as intended". But it's worth a try. Thanks for confirming my intuitions about it.Vig
Yes, the impression they give is that you usually don't need a backing store; it is for the situation where some outside influence can come along asynchronously and change the data. And I notice that in their own examples, such as the Modern Collection Views example, they do not routinely use a backing store. They do sometimes demonstrate how to use one, in case you happen to have one, but they do not use it all the time.Vig
Struct is a value type (swiftbysundell.com/basics/value-and-reference-types) . This was the reason of the confusion with the print statements. When you assign the struct to another variable, it will have another copy of the struct. So changing the value will not effect the initial struct.Modal
I just stumbled over this question because Apple has, with iOS 15, added a new reconfigureItems method which seems to have the same problem as reloadItems where I had to change my model from struct to class in order to get it to work. The doc says that reconfigureItems should be used to update the contents of existing cells without replacing them with new cells but the only difference I spotted so far is that reloadItems triggers prepareForReuse of the cell while reconfigureItems does not.Twospot
@Twospot Thanks for the heads up. I haven't gotten that far in the wwdc videos yet.Vig
Just submitted FB9534050 to Apple. The default behaviour should definitely not be to crashSumac
V
14

(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)
    }
}
Vig answered 1/10, 2020 at 23:41 Comment(5)
This may not be the full story. Though only a single item identifier has been specified to reload in the snapshot, the table view is reloading the entire table. Put a print statement inside the body of the cell configuration block that prints the index path that is being redrawn. All index paths for the table will print every time. It's the equivalent of calling reloadData().Natasha
@Natasha Keep in mind that all of this may be completely changed in iOS 15 (for all I know)...Vig
I did some digging and I am pretty sure this is a bug. It works as expected on iOS 15 running on the iPhone 13 simulator. My Xcode project is set to build for iOS14.7. I tried it on my physical iPhone 12 running iOS14.8.1 and encountered the bug again. So I guess we can considered this "fixed" in IOS15.Natasha
I should add that I tried to create my backing store with 10_000 items and the configuration block was only being called on all the visible cells. UIKit seems to be optimizing the redraws (I think UITableView has always done this). I was worried about a heavy redraw. Still, it's something to be aware of.Natasha
@Natasha Are you suggesting that reloadItems works on iOS 15 for value types without using a backing store (like @Vig has suggested above)?Painstaking
F
7

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)
}
Fong answered 27/9, 2020 at 21:22 Comment(10)
Thanks! It looks like you’re doing exactly what didn’t update the interface for me, so now I need to work out what the difference is.Vig
See, when I do what you did — or at least I thought it was the same thing — I crash with "Invalid item identifier specified for reload". And I presume that this is because the new row identifier is not equatable to any existing row identifier. And that is the point of the question; if they have to be identical, what's to reload?Vig
OK, believe I see the difference. Your date is just a computed property. But suppose, instead, you changed the name property and reloaded. I think you would crash, just like me. And if you try to compensate by implementing == not to involve the name, then the name display does not update when you reload. That is the problem I am trying to solve.Vig
Hmm. Yes, it is odd. I changed timeStamp to be a simple property and I get a crash. If I override == so that it just compares id == id then I don't get a crash, but it doesn't update the timestamp. Interestingly I added a background color computed random property and that does change, so it is correctly calling the closure to update the cell, it just isn't getting the updated timeStamp property.Fong
Yes, try this. Delete the line rowIdentifers.insert(self.arrItems[Int.random(in: 0...self.arrItems.count-1)]) and replace it with var rowid = self.arrItems[Int.random(in: 0...self.arrItems.count-1)]; rowid.name += "!"; rowIdentifers.insert(rowid). That is an actually different item. And you will crash when the app runs. — So your answer is indeed showing that reloadItems is only good if the item is the same item, which is just why I think it's so odd.Vig
Yes to your previous comment, that's exactly the point of my question. We are now completely on the same page, you've understood the question perfectly.Vig
Ok. I worked it out and updated my answer. You can't rely on the item that is passed to the cell provider closure. You need to fetch it from your datasource.Fong
It is not shown in my answer, but I also tested creating a brand new struct that copied the id from the current item, but changed the name and the timestamp and put that in arrData at the appropriate index and it worked.Fong
I'm doing the same sort of thing and it doesn't work for me. I'll post my test example on github. The problem seems to be that, because we have denied that equatability involves these other properties, the diffable datasource doesn't bother to refresh that cell.Vig
Sorry, I did accept this answer but I've had to withdraw, as I just can't get it to work. I'll post a (not) working example.Vig
D
7

I found (via Swift Senpai) that the way you update these diffabledatasource depends on if your model is a class (pass by reference) or struct (pass by value). In the pass by reference you can take the item, update it, then reload the item:

// Model is a class compliant with Hasable and Equatable, name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// modify item
selectedItem.name = "new name"
// update the snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.reloadItems([selectedItem])
dataSource.apply(newSnapshot)

So the above code will work with a model that is a class (the class needs to explicitly implement hast(into:) and ==(lhs:rhs:)).

On the other hand, a struct requires you to copy the item, update it, then insert the updated item and delete the old item from the snapshot.

// Model is a struct with name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// update the item
var updatedSelectedItem = selectedItem
updatedSelectedItem.name = "new name"
// update snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.insertItems([updatedSelectedItem], beforeItem: selectedItem)
newSnapshot.deleteItems([selectedItem])
dataSource.apply(newSnapshot)

These worked for me.

Dioptric answered 16/7, 2021 at 18:17 Comment(3)
Thanks but you'll notice that in the second example you didn't say reloadItems. That is exactly what the question is about, so this doesn't respond to the actual question beyond what I've already said.Vig
I suppose the point is, if you have a struct, reloadItems doesn't work. You have to insert and delete items. reloadItems will just reload the original struct values because they are passed by value.Dioptric
Well I think that point has already been made, eg by https://mcmap.net/q/377664/-what-is-nsdiffabledatasourcesnapshot-reloaditems-for. And I've shown in my own answer how to make reloadItems work even for a struct.Vig
U
5

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)
}
Uxoricide answered 12/11, 2020 at 11:53 Comment(4)
try to flag the question as duplicate, if you really think it is a duplicate, to help keep the site clean.Bifarious
Hi @DaemonPainter I don't agree with the accepted answer here though...Uxoricide
you may comment the accepted answer here, then. Also, the accepted answer may change over time.Bifarious
Came across same issue in iOS15 (worked before 15). Converting struct to class solved the problem.Telfer
C
0

I have found an official answer. reloadItems behavior on NSDiffableDataSourceSnapshot

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.

Collotype answered 13/7 at 7:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.