A couple of months ago, I have wrote an article about “The Modern Ways to Reload Your Table and Collection View Cells“. The concept is simple, we first identify the desired data source snapshot item, update it, and then apply the changes to the data source snapshot.
When It comes to reloading an expandable list header, things get a little bit trickier. This is because we are now dealing with a diffable data source section snapshot, which is another layer deeper into the data source snapshot.
In this article, I would like to share with you the approach I use to effectively reload an expandable list header.
The Sample App
The best way to showcase the approach is by using a sample app.
In our sample app, we will use the diffable data source to construct an expandable list consisting of 2 sections. Every time when a cell is selected, its section header title will be updated accordingly.
Getting Ready
As usual, we must first define the section and item identifier for our list. For better code readability, let’s define a Child
struct to hold the cell’s title and a Parent
struct to hold the section’s title and all its children. With both Parent
and Child
struct in place, let’s construct our sample app’s model objects as well.
// MARK: Identifier Types
struct Parent: Hashable {
var title: String
let children: [Child]
}
struct Child: Hashable {
let title: String
}
// MARK: Model objects
let parents = [
Parent(title: "Parent 1", children: [
Child(title: "Child 1 - A"),
Child(title: "Child 1 - B"),
Child(title: "Child 1 - C"),
]),
Parent(title: "Parent 2", children: [
Child(title: "Child 2 - A"),
Child(title: "Child 2 - B"),
Child(title: "Child 2 - C"),
Child(title: "Child 2 - D"),
]),
]
In order to use both Parent
and Child
type in our data source, we then need to define a data type wrapper as the data source’s item identifier type. Let’s name it DataItem
.
enum DataItem: Hashable {
case parent(Parent)
case child(Child)
}
var dataSource: UICollectionViewDiffableDataSource<Parent, DataItem>!
var collectionView: UICollectionView!
With all that in place, we can now proceed to construct the expandable list.
Building the Expandable List
If you have read my previous article about how to build an expandable list, you should feel very familiar with the following set of codes. If you haven’t, I highly encourage you to check it out before proceeding.
In short, our ultimate goal for this section is to convert the model objects we created earlier into a data source snapshot with the following structure:
override func viewDidLoad() {
super.viewDidLoad()
// MARK: Create list layout
var layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
layoutConfig.headerMode = .firstItemInSection
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
// MARK: Configure collection view
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: listLayout)
collectionView.delegate = self
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 0.0),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
])
// MARK: Cell registration
let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Parent> {
(cell, indexPath, parent) in
// Set parent's title to header cell
var content = cell.defaultContentConfiguration()
content.text = parent.title
cell.contentConfiguration = content
let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header)
cell.accessories = [.outlineDisclosure(options:headerDisclosureOption)]
}
let childCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Child> {
(cell, indexPath, child) in
// Set child title to cell
var content = cell.defaultContentConfiguration()
content.text = child.title
cell.contentConfiguration = content
}
// MARK: Initialize data source
dataSource = UICollectionViewDiffableDataSource<Parent, DataItem>(collectionView: collectionView) {
(collectionView, indexPath, listItem) -> UICollectionViewCell? in
switch listItem {
case .parent(let parent):
// Dequeue header cell
let cell = collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration,
for: indexPath,
item: parent)
return cell
case .child(let child):
// Dequeue cell
let cell = collectionView.dequeueConfiguredReusableCell(using: childCellRegistration,
for: indexPath,
item: child)
return cell
}
}
// Loop through each parent items to create a section snapshots.
for parent in parents {
// Create a section snapshot
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<DataItem>()
// Create a parent DataItem & append as parent
let parentDataItem = DataItem.parent(parent)
sectionSnapshot.append([parentDataItem])
// Create an array of child items & append as children of parentDataItem
let childDataItemArray = parent.children.map { DataItem.child($0) }
sectionSnapshot.append(childDataItemArray, to: parentDataItem)
// Expand this section by default
sectionSnapshot.expand([parentDataItem])
// Apply section snapshot to the respective collection view section
dataSource.apply(sectionSnapshot, to: parent, animatingDifferences: false)
}
}
With the above code in place, you can now run the sample app and see an expandable list being displayed on the screen. If you try to tap on the cells, nothing will happen to the section header title. Don’t worry, we will work on that next.
Let’s Reload the Header
As you might have expected, we will reload the section header in the collectionView(collectionView:didSelectItemAt:)
delegate method.
As mentioned earlier, we are using DataItem
as our item identifier type, which is a value type. Therefore, we can reload the section header using a strategy similar to reloading a cell with value type data, instead of changing the data source snapshot, this time we will have to manipulate the section snapshot.
Here’s how:
func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
let selectedSection = dataSource.snapshot().sectionIdentifiers[indexPath.section]
let selectedSectionSnapshot = dataSource.snapshot(for: selectedSection)
// 1
// Obtain a reference to the selected parent
guard
let selectedParentDataItem = selectedSectionSnapshot.rootItems.first,
case let DataItem.parent(selectedParent) = selectedParentDataItem else {
return
}
// 2
// Obtain a reference to the selected child
let selectedChildDataItem = selectedSectionSnapshot.items[indexPath.item]
guard case let DataItem.child(selectedChild) = selectedChildDataItem else {
return
}
// 3
// Create a new parent with selectedChild's title
let newParent = Parent(title: selectedChild.title, children: selectedParent.children)
let newParentDataItem = DataItem.parent(newParent)
// 4
// Avoid crash when user tap on same cell twice (snapshot data must be unique)
guard selectedParent != newParent else {
return
}
// 5
// Replace the parent in section snapshot (by insert new item and then delete old item)
var newSectionSnapshot = selectedSectionSnapshot
newSectionSnapshot.insert([newParentDataItem], before: selectedParentDataItem)
newSectionSnapshot.delete([selectedParentDataItem])
// 6
// Reconstruct section snapshot by appending children to `newParentDataItem`
let allChildDataItems = selectedParent.children.map { DataItem.child($0) }
newSectionSnapshot.append(allChildDataItems, to: newParentDataItem)
// 7
// Expand the section
newSectionSnapshot.expand([newParentDataItem])
// 8
// Apply new section snapshot to selected section
dataSource.apply(newSectionSnapshot, to: selectedSection, animatingDifferences: true) {
// The cell's select state will be gone after applying a new snapshot, thus manually reselecting the cell.
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .left)
}
}
Let’s break down the above code in details:
- Obtain a reference to the parent of the selected child. This is needed later on when we want to create a new parent with an updated title.
- Obtain a reference to the selected child so that we know what is the title of the new parent.
- Create a new parent that has the same children as the selected parent but with an updated title (selected child’s title).
- This part is important to avoid the
NSInternalInconsistencyException
! As we all know, all items in the data source snapshot must be unique. If users tap on the same cell twice, thenselectedParent
(already in data source snapshot) andnewParent
will be the same. When this happens, we should not proceed to insert thenewParent
into the data source snapshot. - Create a new copy of the selected section snapshot and replace the selected parent item with the new parent item we created earlier.
- When we replaced the selected parent item, we removed the entire section snapshot data hierarchy altogether. Thus we must reconstruct the section snapshot by re-appending all the child items to the new parent item.
- A section snapshot section is collapsed by default, therefore we need to manually expand the section represented by the new parent item.
- Lastly, apply the new section snapshot to the selected section, then we are done!
That’s about it, build and run your code and see everything comes together. You can get the full sample code of this article here.
Dealing with Reference Type
At this point, you might be wondering, what happens if both Parent
and Child
are reference type? Will the sample code still work correctly?
The answer is yes.
This might sound unrealistic at first, but if you look closely, the way we update the section title is by creating a new parent object with the desired title and set it to the section snapshot. Therefore, it doesn’t matter Parent
is a class or struct, the way to create a new object is still the same.
Furthermore, since we have created a data type wrapper (DataItem
) to wrap around both Parent
and Child
type, changing both of these types to reference type will not affect the code where we update the section snapshot. This is because as long as we are still using DataItem
as an item identifier type, everything will still work correctly.
Wrapping Up
There you have it! Reloading a section header when using a diffable data source might seem overly complicated at first. But once you understand the concept behind and how it works under the hood, it is just a matter of replacing the old section snapshot with a new one.
Feel free to follow me on Twitter, and subscribe to my monthly newsletter for more articles related to iOS development.
Further Readings
- Table and Collection View Cells Reload Improvements in iOS 15
- Replicate the Expandable Date Picker Using UICollectionView List
- The Undocumented Facts of Diffable Data Source Section Snapshot
- UICollectionView List with Interactive Custom Header
- UICollectionView List with Custom Cell and Custom Configuration
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.