Carlos Santos

iOS Dev

Keeping expansion state of OutlineDisclosures using NSDiffableDataSource SectionSnapshot on UICollectionView DiffableDatasource

2020-10-25 5 mintech

unsplash.com unsplash-logoZan

I have a never-ending pet-project app, an MQTT visualizer, it’s kind of my playground where I test all the new APIS Apple provides us. (pretty sure it won’t see the light of day, but at least keeps me testing all the new APIs I cannot use professionaly, due to minimum deployment targets).

Use case

So what’s my use case? This is an MQTT observer app, so I receive a lot of updates from the broker (mosquitto) throughout the app’s lifecycle (really a lot).

It’s handling all the data flow via Combine Publishers/Subscribers.

Think my datastructure as a compositional list of MQTT Topics, with every part of the topic, nested inside:

e.g.: Notice the following topics (and by topic mean some/string/slash/separated):

  • shellies/shelly1pm-51b1904/relay/0
  • shellies/shelly1pm-51b1904/temperature

As you can see, they’re just different levels, slash separated.

And they are converted into this compositional structure, using the slash as a hierarchy divider.

-> shellies
    -> shelly1pm-51b1904
        -> temperature
        -> relay
            -> 0

Each part of the topic is another node on the tree, and you can see where this is going 😀

Yes, I’m using a Trie to hold all the topics tree.

(If you want to see an example of a Trie implementation in Swift, head to Ray Wenderlich’s Swift Algorithm Club repo. They have tons of examples of a lot of datastructures, and Trie is one of them)

Using this struct, I can represent a full Topic:

struct TopicModel {

    let topicPart: String
    let subTopics: [TopicModel]
}

I’m also using a Dictionary: [TopicNode: TopicHistory] that hold the last messages each topic received, but that’s subject for another post.

WWDC 2020

This time, while seeing #WWDC2020 videos I bumped into NSDiffableDataSourceSectionSnapshot I thought it would the perfect fit for my MQTT Topic list.

I need to implement a collapsible tree structured menu for my MQTT topic list.

Followed by that video: Advances in Diffable Data Sources - Session 10045 I’ve downloaded the sample code, and it contained a lot of examples: from Compositional layouts to Diffable Datasources, like this Emoji Explorer:

SampleApp

This is really simple way to enable collapsible outline disclosure using UICollectionviewCell.

Let’s say I have an Hashable model I use to represent each MQTT Topic part, called TopicModel (and that’s all you need, an Hashable item).

Let’s build a closure that we’ll use to pass to our dequeueConfiguredReusableCell method:

let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, TopicModel> { cell, _, topic in
    var contentConfiguration = cell.defaultContentConfiguration()
    // checking isLeaf because I want this one to collapse/expand children topics
    if !topic.isLeaf {
        contentConfiguration.text = "Parent topic"
        let disclosureOptions = UICellAccessory.OutlineDisclosureOptions(style: .header)
        cell.accessories = [.outlineDisclosure(options: disclosureOptions)]
    } else {
        contentConfiguration.text = "Nested topic"
        cell.accessories = []
    }
}

...
dataSource = UICollectionViewDiffableDataSource<TopicSection, TopicModel>(collectionView: collectionView) {
    (collectionView: UICollectionView, indexPath: IndexPath, item: TopicModel) -> UICollectionViewCell? in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
}

And it’s done! Bam! Simple as that.

In less than 1h I completely converted my old UITableView (as it was originally) into a UICollectionView, and I started using UICollectionViewDiffableDataSource with NSDiffableDataSourceSectionSnapshot.

Problem

But as soon as I started feeding my UICollectionView with a bunch of snapshot updates, I saw the Outline Disclosure cells collapsing automatically 🤔.

Oh damn. What happened? 💥

So, after some digging, and spamming some questions on twitter, apparently NSDiffableDataSourceSectionSnapshot<Item> keeps the expansion state internally, but if you apply another snapshot into the same datasource, the expansion state is reseted. So I had to figure out a way of keeping the Node expanded.

How did I solve expansion state persistence

Let’s say I have an Hashable model I use to represent each MQTT Topic part, called TopicModel.

Using UICollectionViewDiffableDatasource’s SectionSnapshotHandler (available since iOS 14), you can handle multiple events:

  • var willExpandItem: (Item) -> Void
  • var willCollapseItem: (Item) -> Void

This way you can keep a set of Topics like Set<TopicModel>, and insert/remove on each expand/collapse event.

private var expandedNodes = Set<TopicModel>()

...

dataSource.sectionSnapshotHandlers.willCollapseItem = { [weak self] topicModel in
    self?.expandedNodes.remove(topicModel)
}
dataSource.sectionSnapshotHandlers.willExpandItem = { [weak self] topicModel in
    self?.expandedNodes.insert(topicModel)
}

This way, every time you apply a new snapshot, you need to specify which nodes were expanded before applying the snapshot.

Here’s an updated function (from the original Apple sample code for that session) that receives a compositional list of TopicModel (where the nested topics are available inside subTopics property):

    func snapshot(from topics: [TopicModel]) -> NSDiffableDataSourceSectionSnapshot<TopicModel>() {
        var snapshot = NSDiffableDataSourceSectionSnapshot<TopicModel>()

        var nodesToExpand = Set<TopicModel>()

        func addItems(_ menuItems: [TopicModel], to parent: TopicModel?) {
            snapshot.append(menuItems, to: parent)
            for menuItem in menuItems where !menuItem.subTopics.isEmpty {
                if expandedNodes.contains(menuItem) {
                    // I'll check here if the new one is an expanded one
                    // and I should mark it as "to expand" on the next snapshot
                    nodesToExpand.insert(menuItem)
                }
                addItems(menuItem.subTopics, to: menuItem)
            }
        }
        addItems(topics, to: nil)
        snapshot.expand(Array(nodesToExpand)) // Here we're reflecting the expansion state on our datasource

        return snapshot
    }

    func update(with topics: [TopicModel] {}
        let snapshot = snapshot(from: topics)

        // Then you can apply this up2date snapshot, with all the expansion states reflected
        dataSource.apply(snapshot, to: .topics, animatingDifferences: true)
    }

And expansion state is kept, even with a 1000 MQTT updates on this wonderful tree 💪

Here’s a sneak peak of this collapsible setup:

Expand and collapse

This objective of this article is not to guide you on what both WWDC session and sample app do, but it clarifies and shows a possible approach that you can use if you want to keep the exansion state while applying multiple updates to your datasource over time.