UICollectionView - Section Snapshots - "Failed to find index of item" when removing sections or items

1.4k views Asked by At

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!

2

There are 2 answers

0
John Wells On

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…

func applySnapshot(with cheeses: [Cheese]?) {
    var snapshot = NSDiffableDataSourceSnapshot<CheeseType, ListItem>()

    snapshot.appendSections(CheeseType.allCases)
    dataSource?.apply(snapshot)
    
    for cheeseType in CheeseType.allCases {
        var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
        
        let header = ListItem.header(cheeseType)
        sectionSnapshot.append([header])
        sectionSnapshot.expand([header])
        
        if let cheeses = cheeses {
            let matchingCheese = cheeses.filter { $0.cheeseType == cheeseType }
            let cheeseItems = matchingCheese.map { ListItem.content($0) }
            sectionSnapshot.append(cheeseItems, to: header)
        }
        
        dataSource?.apply(sectionSnapshot, to: cheeseType)
    }
}

…to something like this.

func applySnapshot(with cheeses: [Cheese]?) {
    /* Instead of creating a blank snapshot every time,
       source a snapshot from dataSource if possible and
       only create a blank snapshot if that's not possible */
    var snapshot = dataSource?.snapshot() ?? NSDiffableDataSourceSnapshot<CheeseType, ListItem>()

    /* Need to prevent sections that already exist in
       the snapshot from being added again.
       In this case, checking if sectionIdentifiers is
       empty is adequate since sections never change -
       other cases may need more involved checking */
    if snapshot.sectionIdentifiers.isEmpty {
        snapshot.appendSections(CheeseType.allCases)
        dataSource?.apply(snapshot)
    }
    
    for cheeseType in CheeseType.allCases {
        var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
        
        let header = ListItem.header(cheeseType)
        sectionSnapshot.append([header])
        sectionSnapshot.expand([header])
        
        if let cheeses = cheeses {
            let matchingCheese = cheeses.filter { $0.cheeseType == cheeseType }
            let cheeseItems = matchingCheese.map { ListItem.content($0) }
            sectionSnapshot.append(cheeseItems, to: header)
        }
        
        dataSource?.apply(sectionSnapshot, to: cheeseType)
    }
}
0
cristian_064 On

I had the same error and my solution was:

    var collapseSectionSnapshot = NSDiffableDataSourceSectionSnapshot<CollapsibleElementType>()
    elements.forEach { collapseHeader in
        let item = CollapsibleElementType.header(collapseHeader)
        collapseSectionSnapshot.append([item])
        let collpasibleElement = collapseHeader.collapsibleElements.map({CollapsibleElementType.element($0)})
        collapseSectionSnapshot.append(collpasibleElement, to: item)
    }
    dataSource.apply(collapseSectionSnapshot, to: .main, animatingDifferences: false)

I don't create NSDiffableDataSourceSnapshot I only use the above code.