40

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?

pkamb
  • 33,281
  • 23
  • 160
  • 191
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • @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. – matt Sep 30 '20 at 20:03
  • 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. – matt Sep 30 '20 at 20:04
  • 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_? – matt Sep 30 '20 at 20:06
  • 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. – Paulw11 Sep 30 '20 at 20:08
  • 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. – Paulw11 Sep 30 '20 at 20:10
  • @Paulw11 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. – matt Sep 30 '20 at 20:21
  • 1
    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. – Paulw11 Sep 30 '20 at 21:13
  • 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. – matt Sep 30 '20 at 21:32
  • 1
    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. – matt Sep 30 '20 at 21:42
  • Struct is a value type (https://www.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. – Bahri Okuroglu Dec 22 '20 at 21:00
  • 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. – PatrickDotStar Jun 09 '21 at 17:52
  • @PatrickDotStar Thanks for the heads up. I haven't gotten that far in the wwdc videos yet. – matt Jun 09 '21 at 19:47
  • Just submitted FB9534050 to Apple. The default behaviour should definitely not be to crash – Daniel Galasko Aug 19 '21 at 09:40

4 Answers4

12

(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)
    }
}
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 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(). – MH175 Dec 04 '21 at 01:22
  • @MH175 Keep in mind that all of this may be completely changed in iOS 15 (for all I know)... – matt Dec 04 '21 at 01:27
  • 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. – MH175 Dec 04 '21 at 08:18
  • 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. – MH175 Dec 04 '21 at 08:54
  • @MH175 Are you suggesting that `reloadItems` works on iOS 15 for value types without using a backing store (like @matt has suggested above)? – Kunal Shah Jul 31 '22 at 03:41
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)
}
Paulw11
  • 108,386
  • 14
  • 159
  • 186
  • 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. – matt Sep 27 '20 at 21:40
  • 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? – matt Sep 27 '20 at 22:04
  • 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. – matt Sep 27 '20 at 22:06
  • 1
    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. – Paulw11 Sep 27 '20 at 22:17
  • 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. – matt Sep 27 '20 at 22:22
  • 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. – matt Sep 27 '20 at 22:23
  • 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. – Paulw11 Sep 27 '20 at 22:29
  • 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. – Paulw11 Sep 27 '20 at 22:35
  • 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. – matt Sep 27 '20 at 22:58
  • 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. – matt Sep 30 '20 at 19:41
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.

Jim Kardach
  • 71
  • 1
  • 1
  • 2
    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. – matt Jul 16 '21 at 20:02
  • 1
    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. – Jim Kardach Jul 27 '21 at 03:25
  • Well I think that point has already been made, eg by https://stackoverflow.com/a/64093453/341994. And I've shown in my own answer how to make reloadItems work even for a struct. – matt Jul 27 '21 at 03:56
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)
}
bobby123uk
  • 892
  • 4
  • 17