Keeping expansion state of OutlineDisclosures using NSDiffableDataSource SectionSnapshot on UICollectionView DiffableDatasource
/ 5 min read
Table of Contents
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/0shellies/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 -> 0Each 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:
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) -> Voidvar 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:
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.