Diffable Data Sources and Data Storage — Part 2

This is the second part of a two-part article about iOS diffable data sources. If you haven’t read the first part, please go back and read it now!

Inside and Outside

In the first part of this article, I created a table view whose data source consisted simply of the names of the U.S. states, divided into sections by the first letter of the state name. The idea was to supply this data to the table view using a diffable data source. With a diffable data source, all data items must be unique. Initially, I used the state name as the unique identifier of each item of data; but, as I pointed out, if it happened that two states had the same name, we’d need to use some other feature of the data, such as a UUID, as the unique identifier.

In my example, I handed all the data over to the diffable data source. The diffable data source is thus not only supplying the data to the table view; it is also storing the data. But if the name of the state is not a determinant of how a cell is identified, why does that part of the data need to live inside the data source in the first place? The only thing that really matters here, as far as the diffable data source itself is concerned, is, in fact, the unique identifier. The state name could live anywhere.

This suggests a sort of hybrid architecture, where some data lives inside the diffable data source, and other data is maintained in our own data structure — an external backing store. We won’t have any trouble laying our hands on the right piece of external data in the backing store because the UUID is unique. For instance, we might use a simple dictionary keyed by the UUID:

var backingStore = [UUID:String]()

Our data source and snapshot are now of type <String,UUID>. When we populate the initial snapshot, we provide only UUIDs — but at the same time, we also populate the backing store:

var snap = NSDiffableDataSourceSnapshot<String,UUID>()
for section in sections {
    snap.appendSections([section.0])
    let uuids = section.1.map {_ in UUID()}
    snap.appendItems(uuids)
    for (uuid,name) in zip(uuids, section.1) {
        self.backingStore[uuid] = name
    }
}
self.datasource.apply(snap, animatingDifferences: false)

The data source’s cell provider function now receives a UUID and looks up the state name in the backing store:

tv, ip, uuid in // table view, index path, and UUID
guard let s = self.backingStore[uuid] else { fatalError("oop") }

That example might seem a bit silly, but there are situations where the architecture I’m suggesting could make a lot of sense. Suppose our data has the ability to change spontaneously. Perhaps we are registered for some notification or subscribed to some Combine publisher, such that a change in our data can flow in from the world outside of this class at any time (“asynchronously”). The new data comes in, and we (somehow) locate the old data to which it corresponds and update it — and now we need to tell the table view to reload its data as well so that the updated data is displayed in the interface.

But we can’t actually call tableView.reloadData(); that’s forbidden when you use a diffable data source. So instead, we grab a snapshot from the diffable data source and call reloadItems for the items that have changed in the backing store. This causes the cell provider function to be called again, and so we reach out to the backing store again and configure the cell again, and so the cell is now displaying the updated data.

User-Initiated Data Changes

In fact, I can think of a very common situation where the data can be changed spontaneously (“asynchronously”) that you’ve almost certainly encountered numerous times: when there’s some sort of user-interactive interface inside the cell.

To illustrate, let’s suppose we want the user to be able to designate any of the U.S. states in our table view as a favorite. To facilitate this, I’ll add a star to the cell; if the user taps the star, it toggles between filled (to indicate that this state is a favorite) and unfilled (to indicate that it isn’t). Now, for each state, in addition to its name, we need to maintain another piece of data, namely, whether the user has designated this state a favorite. So I’ll create a struct:

struct StateData {
    let name: String
    var isFavorite: Bool
}

And my backing store changes its type to a dictionary of StateData:

var backingStore = [UUID:StateData]()

When I create the initial data, I’ll just make isFavorite be false for every state:

var snap = NSDiffableDataSourceSnapshot<String,UUID>()
for section in sections {
    snap.appendSections([section.0])
    let uuids = section.1.map {_ in UUID()}
    snap.appendItems(uuids)
    for (uuid,name) in zip(uuids, section.1) {
        self.backingStore[uuid] = StateData(name: name, isFavorite: false)
    }
}
self.datasource.apply(snap, animatingDifferences: false)

When I configure the cell, I’ll give it an accessory view consisting of a tappable image view whose image is either star.fill or star, depending on whether this state is or is not a favorite:

var imageView = cell.accessoryView as? UIImageView
if imageView == nil {
    let iv = UIImageView()
    iv.isUserInteractionEnabled = true
    let tap = UITapGestureRecognizer(target: self, action: #selector(self.starTapped))
    iv.addGestureRecognizer(tap)
    cell.accessoryView = iv
    imageView = iv
}
let starred = self.backingStore[uuid]?.isFavorite ?? false
imageView?.image = UIImage(systemName: starred ? "star.fill" : "star")
imageView?.sizeToFit()

All the action is now in what happens when the user taps a star. We have arranged that this should call our starTapped method. We work out what cell this is, using a handy UIResponder extension:

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
        }
    }
}

From there, we can ask the table view what index path this is, and then we can ask the diffable data source for the UUID of the data. For that UUID, we revise the data in the backing store. Finally, we tell the diffable data source to reload this item:

@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 uuid = self.datasource.itemIdentifier(for: ip) else {return}
    guard var data = self.backingStore[uuid] else {return}
    data.isFavorite.toggle()
    self.backingStore[uuid] = data
    var snap = self.datasource.snapshot()
    snap.reloadItems([uuid])
    self.datasource.apply(snap, animatingDifferences: false)
}

We obtain a snapshot, reload the UUID whose data has changed, and apply the snapshot. That causes the cell provider function to be called again. This time, it gets a different value for the backing store data’s isFavorite, and thus a different value for starred, and thus a different image for the accessory image view. From the user’s point of view, tapping the star toggles its filled/unfilled state. From our point of view, it toggles the data itself.

The Curious Case of reloadItems

The sort of thing I did in the preceding section is doable, it turns out, only if we are using an external backing store. (This discovery, arrived at after many days of experimentation, is what actually prompted me to write this article in the first place.) The reason has to do with the peculiar behavior of reloadItems.

Suppose we wanted to keep all the data inside the diffable data source. You can easily imagine that this might take the form of a struct uniting (once again) the UUID with all the rest of the data:

struct StateData : Hashable {
    let uuid: UUID
    let name: String
    let isFavorite: Bool
}

The diffable data source and snapshot type would then be <String,StateData>, and we would have no backingStore property any longer.

Okay, so let’s say the user taps a star. We respond by making a new StateData object, copied from the old one so that it has the same uuid and name, but with a different isFavorite value. We then grab a snapshot and tell it to reloadItems with this new StateData object, and guess what? We crash! The runtime objects to what we are doing, on the grounds that we are trying to reload an item that is not already in the snapshot.

This is not as surprising as you might suppose. The data source needs to know that the new StateData object is, in some meaningful sense, “the same” as the existing StateData object that we started with. We know, because we created the StateData struct for this purpose, that “the same” means “having the same uuid value.” But we have not told the data source about that! We are using a synthesized version of Hashable and Equatable, and according to that synthesized version, all three properties must be the same in order for two StateData instances to be considered “the same.”

Okay, so let’s try to fix that by redefining what it means to be Hashable and Equatable, in such a way that those things depend upon the uuid alone. No problem, there’s a way to do that:

struct StateData : Hashable {
    let uuid: UUID
    let name: String
    let isFavorite: Bool
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }
    static func ==(lhs:StateData, rhs:StateData) -> Bool {
        lhs.uuid == rhs.uuid
    }
}

That seems like a great idea, but it doesn’t work. The reason is that when we create our new StateData object and call reloadItems for it, the changed value of isFavorite never gets into the snapshot. Evidently, the snapshot says to itself: “Well, I can see using the definition of Equatable that this new StateData is exactly the same as the old StateData — because they have the same uuid, which is all that counts. So there is no work for me to do; I’ll just ignore the reloadItems call altogether.” The application of the snapshot to the data source causes the cell to be reloaded and the cell provider function to be called again, but the data source itself contains exactly the same data as it contained before, and so the filled state of the star in the interface doesn’t change.

Personally, I regard that behavior as a bug. In effect, there’s a Catch-22 situation here. If the item you ask to reload is not the same as an item already in the data source, you crash. But if the item you ask to reload is the same as an item already in the data source, there’s nothing to reload!

Using an external backing store, as I demonstrated in the previous section, is a workaround for the whole problem. That way, reloading the item does have an effect. The cell provider function is called with the same data, yes, but the cell provider function looks not just at that data but also at the backing store. Thus, if the backing store has changed, the cell provider function picks up that change and reads it into the cell’s interface.

Check out Part 1!

You Might Also Like…

Little Swift Tricks: Boxing Multiple Types

Here’s a little Swift language trick I sometimes use — more often than you might suppose, actually. As I’m sure you know, Swift is very strict about the types of its objects. You have to declare clearly what type a reference is, and from then on, you have to stick to that. That’s one of …

Little Swift Tricks: Boxing Multiple Types Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.