Keeping expansion state of OutlineDisclosures using NSDiffableDataSource SectionSnapshot on UICollectionView DiffableDatasource
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:
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:
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.