Why is UICollectionViewDiffableDataSource reloading every cell when nothing has changed?

2.8k Views Asked by At

I've created the following demo view controller to reproduce the issue in a minimal example.

Here I'm applying a snapshot of the same data repeatedly to the same collection view using UICollectionViewDiffableDataSource and every time all of the cells are reloaded even though nothing has changed.

I'm wondering if this is a bug, or if I'm "holding it wrong".

It looks like this other user had the same issue, though they didn't provide enough information to reproduce the bug exactly: iOS UICollectionViewDiffableDataSource reloads all data with no changes

EDIT: I've also uncovered a strange behavior - if animating differences is true, the cells are not reloaded every time.

import UIKit

enum Section {
    case all
}

struct Item: Hashable {
    var name: String = ""
    var price: Double = 0.0

    init(name: String, price: Double) {
        self.name = name
        self.price = price
    }
}


class ViewController: UIViewController {
    private let reuseIdentifier = "ItemCell"
    private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
    private lazy var dataSource = self.configureDataSource()

    private var items: [Item] = [
        Item(name: "candle", price: 3.99),
        Item(name: "cat", price: 2.99),
        Item(name: "dribbble", price: 1.99),
        Item(name: "ghost", price: 4.99),
        Item(name: "hat", price: 2.99),
        Item(name: "owl", price: 5.99),
        Item(name: "pot", price: 1.99),
        Item(name: "pumkin", price: 0.99),
        Item(name: "rip", price: 7.99),
        Item(name: "skull", price: 8.99),
        Item(name: "sky", price: 0.99),
        Item(name: "book", price: 2.99)
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        // Configure the collection view:
        self.collectionView.backgroundColor = .white
        self.collectionView.translatesAutoresizingMaskIntoConstraints = false
        self.collectionView.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: self.reuseIdentifier)
        self.collectionView.dataSource = self.dataSource
        self.view.addSubview(self.collectionView)
        NSLayoutConstraint.activate([
            self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.collectionView.topAnchor.constraint(equalTo: self.view.topAnchor),
            self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
        ])

        // Configure the layout:
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)
        self.collectionView.setCollectionViewLayout(layout, animated: false)

        // Update the snapshot:
        self.updateSnapshot()

        // Update the snapshot once a second:
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.updateSnapshot()
        }
    }

    func configureDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
        let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self.collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.reuseIdentifier, for: indexPath) as! ItemCollectionViewCell
            cell.configure(for: item)
            return cell
        }

        return dataSource
    }

    func updateSnapshot(animatingChange: Bool = false) {

        // Create a snapshot and populate the data
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.all])
        snapshot.appendItems(self.items, toSection: .all)

        self.dataSource.apply(snapshot, animatingDifferences: false)
    }
}

class ItemCollectionViewCell: UICollectionViewCell {
    private let nameLabel = UILabel()
    private let priceLabel = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.addSubview(self.nameLabel)
        self.addSubview(self.priceLabel)

        self.translatesAutoresizingMaskIntoConstraints = false
        self.nameLabel.translatesAutoresizingMaskIntoConstraints = false
        self.nameLabel.textAlignment = .center
        self.priceLabel.translatesAutoresizingMaskIntoConstraints = false
        self.priceLabel.textAlignment = .center
        NSLayoutConstraint.activate([
            self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor),
            self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor),
            self.priceLabel.topAnchor.constraint(equalTo: self.nameLabel.bottomAnchor),
            self.priceLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            self.priceLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor),
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(for item: Item) {
        print("Configuring cell for item \(item)")
        self.nameLabel.text = item.name
        self.priceLabel.text = "$\(item.price)"
    }
}
1

There are 1 best solutions below

6
On BEST ANSWER

I think you've put your finger on it. When you say animatingDifferences is to be false, you are asking the diffable data source to behave as if it were not a diffable data source. You are saying: "Skip all that diffable stuff and just accept this new data." In other words, you are saying the equivalent of reloadData(). No new cells are created (it's easy to prove that by logging), because all the cells are already visible; but by the same token, all the visible cells are reconfigured, which is exactly what one expects from saying reloadData().

When animatingDifferences is true, on the other hand, the diffable data source thinks hard about what has changed, so that, if necessary, it can animate it. As a result of all that work behind the scenes, therefore, it knows when it can avoid reloading a cell if it doesn't have to (because it can move the cell instead).

Indeed, when animatingDifferences is true, you can apply a snapshot that reverses the cells, and yet configure is never called again, because moving the cells around is all that needs to be done:

func updateSnapshot(animatingChange: Bool = true) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
    snapshot.appendSections([.all])
    self.items = self.items.reversed()
    snapshot.appendItems(self.items, toSection: .all)
    self.dataSource.apply(snapshot, animatingDifferences: animatingChange)
}

Interestingly, I also tried the above with shuffled instead of reversed, and I found that sometimes some cells are reconfigured. Evidently it is not the main intention of the diffable data source not to reload cells; it's just a sort of side effect.