Search code examples
uicollectionviewuikitdiffabledatasourceuicollectionviewdiffabledatasource

How do I update a collection view supplementary view without giving up on animations?


The following UIKit app uses a collection view with compositional layout (list layout) and diffable data source.

If you run it and then tap on the view controller's right bar button item, the footerText property will change.

How may I update the collection view's footer to display the updated footerText, without interfering with the collection view's animations, and possibly also in a way that is performant and recommended by Apple?

By "collection view's animations" I mean any animation of the collection view or of any of its subviews.

So, for instance, I wouldn't want to reconfigure the contents of the supplementary view by reloading the collection view's data.

You can think of this problem as a problem such as how to keep compositional layout badges up to date without giving up on animations.

class ViewController: UIViewController {
    var collectionView: UICollectionView!
    
    var footerText = "Initial footer text"
    
    var dataSource: UICollectionViewDiffableDataSource<Section, String>!
    
    var snapshot: NSDiffableDataSourceSnapshot<Section, String> {
        var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
        snapshot.appendSections(Section.allCases)
        snapshot.appendItems(["A", "a"], toSection: .first)
        return snapshot
    }
    
    enum Section: CaseIterable {
        case first
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureHierarchy()
        configureDataSource()
    }
    
    func configureHierarchy() {
        navigationItem.rightBarButtonItem = .init(title: "Change footer text", style: .plain, target: self, action: #selector(changeFooterText))
        
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        view.addSubview(collectionView)
        collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
    }
    
    @objc func changeFooterText() {
        footerText = "Secondary footer text"
    }
    
    func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, itemIdentifier in
            var contentConfiguration = UIListContentConfiguration.cell()
            contentConfiguration.text = itemIdentifier
            cell.contentConfiguration = contentConfiguration
        }
        
        dataSource = .init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }
            
        configureSupplementaryViewProvider()
        
        dataSource.apply(self.snapshot)
    }
    
    func configureSupplementaryViewProvider() {
        let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { headerView, elementKind, indexPath in
            
            var contentConfiguration = UIListContentConfiguration.cell()
            contentConfiguration.text = "Header \(indexPath.section)"
            headerView.contentConfiguration = contentConfiguration
        }
        let footerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { [weak self] headerView, elementKind, indexPath in
            
            guard let self else { return }
            
            var contentConfiguration = UIListContentConfiguration.cell()
            contentConfiguration.text = self.footerText
            headerView.contentConfiguration = contentConfiguration
        }
        
        dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
            if kind == UICollectionView.elementKindSectionHeader {
                collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
            } else if kind == UICollectionView.elementKindSectionFooter {
                collectionView.dequeueConfiguredReusableSupplementary(using: footerRegistration, for: indexPath)
            } else {
                nil
            }
        }
    }
    
    func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { section, layoutEnvironment in
            var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
            config.headerMode = .supplementary
            config.footerMode = .supplementary
            return NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
        }
    }
}

What I've tried to do in footerText's didSet:

  1. Reconfiguring the supplementary view provider:
var footerText = "Initial footer text" {
    didSet {
        configureSupplementaryViewProvider()
    }
}
  1. Also re-applying the snapshot:
var footerText = "Initial footer text" {
    didSet {
        configureSupplementaryViewProvider()
        dataSource.apply(self.snapshot)
    }
}
  1. Also re-configuring the items:
var footerText = "Initial footer text" {
    didSet {
        configureSupplementaryViewProvider()
        dataSource.apply(self.snapshot, animatingDifferences: true)

        var snapshot = dataSource.snapshot()
        snapshot.reconfigureItems(snapshot.itemIdentifiers)
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

Solution

  • To directly update a supplementaryView you need to know its kind and section. With your example, you could add a following didSet code to a footerText:

    var footerText = "Initial footer text" {
        didSet {
            guard let footer = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: IndexPath(row: 0, section: 0)) as? UICollectionViewListCell else { return }
            
            var contentConfiguration = UIListContentConfiguration.cell()
            contentConfiguration.text = self.footerText
            footer.contentConfiguration = contentConfiguration
        }
    }
    

    This way each time you change footerText, your footer will be updated. You could also move contentConfiguration update to a separate fuction, I've just copied it from your configureSupplementaryViewProvider