I want to create a collectionView
with dynamic sections that also can be collapsed. This seems to be quite easy with the new section snapshots
in iOS 14. This is what I have (fully working example).
import UIKit
enum Section: Hashable {
case group(Int)
}
enum Item: Hashable {
case header(Int)
case item(String)
}
class ViewController: UIViewController {
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var groups = [1, 2, 3, 4, 5]
private var groupItems: [Int: [String]] = [
1: ["A", "B", "C", "D"],
2: ["E", "F", "G", "H"],
3: ["I", "J", "K", "L"],
4: ["M", "N", "O", "P"],
5: ["Q", "R", "S", "T"],
]
private lazy var collectionView: UICollectionView = {
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .firstItemInSection
let layout = UICollectionViewCompositionalLayout.list(using: config)
let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.backgroundColor = .systemGroupedBackground
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
collectionView.frame = view.bounds
configureDataSource()
applySnapshot()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
self.groups.remove(at: 0) // This removes the entire first section
self.applySnapshot()
}
}
private func configureDataSource() {
let itemCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> {
(cell, indexPath, letter) in
var content = cell.defaultContentConfiguration()
content.text = letter
cell.contentConfiguration = content
}
let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Int> {
(cell, indexPath, number) in
var content = cell.defaultContentConfiguration()
content.text = String(number)
cell.contentConfiguration = content
let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header)
cell.accessories = [.outlineDisclosure(options: headerDisclosureOption)]
}
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
switch item {
case .header(let number):
return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: number)
case .item(let letter):
return collectionView.dequeueConfiguredReusableCell(using: itemCellRegistration, for: indexPath, item: letter)
}
})
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(groups.map { .group($0) })
// This line causes the error messages. If I comment it out, the messages go away but changing sections (removing or adding some) doesn't work any more (the collectionview does not reflect the changes of the sections)
dataSource.apply(snapshot, animatingDifferences: false)
for group in groups {
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
// header
let headerItem = Item.header(group)
sectionSnapshot.append([headerItem])
// items
sectionSnapshot.append((groupItems[group] ?? []).map { Item.item($0) }, to: headerItem)
sectionSnapshot.expand([headerItem])
dataSource.apply(sectionSnapshot, to: .group(group))
}
}
}
It's just a simple collection view showing a few sections with 4 items each. To demonstrate my problem, I've added a closure that is automatically called 2 seconds after loading the view controller. It removes the first section and updates the collectionview's data source.
There's two problems:
First, it gives me these error messages:
sectionSnapshotExample[15380:1252802] [DiffableDataSource] Failed to find index of item sectionSnapshotExample.Item.header(1)
sectionSnapshotExample[15380:1252802] [DiffableDataSource] Failed to find index of item sectionSnapshotExample.Item.header(4)
sectionSnapshotExample[15380:1252802] [DiffableDataSource] Failed to find index of item sectionSnapshotExample.Item.header(2)
sectionSnapshotExample[15380:1252802] [DiffableDataSource] Failed to find index of item sectionSnapshotExample.Item.header(3)
sectionSnapshotExample[15380:1252802] [DiffableDataSource] Failed to find index of item sectionSnapshotExample.Item.header(5)
Secondly, the update is visually not nice because it reloads the entire collection view (all cells are crossfading to themselves, even though they haven't changed).
The error is caused inside the applySnapshot()
method:
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(groups.map { .group($0) })
// This line causes the error messages
dataSource.apply(snapshot, animatingDifferences: false)
....
}
This applies the sections to the data source (Apple is doing the same in their example projects).
If I comment this line out, then the sections aren't updated anymore (in this example, the deleted section would stay in the collection view.
Any ideas? Am I doing it wrong? Are section snapshots
not meant to be used with dynamic content? If so, is there another simple way to have collapsible sections?
Thanks!
I ran into this issue earlier today, and this post was the only reference to it that I could find anywhere on the internet.
After a few hours of tinkering I figured out a fix. It seems that
UICollectionViewDiffableDataSource
has problems with automatic diffing when you try to expand headers for pre-existing sections while applying a snapshot with new content.So instead of letting it always calculate the diff fully automatically by creating a new top level snapshot with no content, you need to instead have your data source generate a snapshot, check if the sections you're adding content to already exist in the snapshot, and then only apply a new top level snapshot if there's missing sections.
In practice, this looks like going from this…
…to something like this.