UICollectionView Diffable Data Source Explained
For years, managing data in UICollectionView (and UITableView) was a manual and often error-prone process. We'd update our underlying data model, then call reloadData() or meticulously calculate insertItems(at:), deleteItems(at:), and reloadItems(at:) to animate changes. This often led to out-of-sync data, crashes due to incorrect index paths, and UI glitches. If you've ever battled NSInternalInconsistencyException or Invalid update: invalid number of sections, you know the pain.
Enter UICollectionViewDiffableDataSource (and its table view counterpart UITableViewDiffableDataSource), introduced at WWDC 2019. This powerful API fundamentally changes how we interact with UICollectionView data, making it safer, simpler, and more robust. It leverages Apple's diffing algorithm to automatically calculate the differences between two states of your data and apply the minimal set of updates to the collection view, complete with seamless animations.
In this article, we'll dive deep into UICollectionViewDiffableDataSource, exploring its core concepts, how to set it up, and best practices for managing your collection view data with confidence.
Understanding the Core Concepts
At the heart of UICollectionViewDiffableDataSource are two main components:
UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>: This is the data source object itself. You initialize it with your collection view and a cell provider closure. It manages the communication between your data and the collection view.SectionIdentifierType: A type that uniquely identifies your sections.ItemIdentifierType: A type that uniquely identifies your items within sections.- Crucially, both
SectionIdentifierTypeandItemIdentifierTypeMUST conform toHashable. This is what allows the diffing algorithm to efficiently compare items and sections.
NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>: This is a lightweight, immutable representation of the current state of your collection view's data. You create a snapshot, populate it with your sections and items, and then apply it to thediffableDataSource. The diffable data source then compares this new snapshot with its previous state, calculates the differences, and performs the necessary updates on the UICollectionView.
Setting Up a Basic Diffable Data Source
Let's walk through setting up a simple UICollectionView that displays a list of items using DiffableDataSource.
First, define your SectionIdentifierType and ItemIdentifierType. For simplicity, we'll use enums here, but structs or even String / UUID can work as long as they are Hashable.
import UIKit
// 1. Define Section and Item Identifiers
enum Section: Hashable {
case main
case favorites
}
struct Fruit: Hashable {
let name: String
let emoji: String
let id = UUID() // Essential for unique identification if name isn't unique
func hash(into hasher: inout Hasher) {
hasher.combine(id) // Only hash the unique ID
}
static func == (lhs: Fruit, rhs: Fruit) -> Bool {
lhs.id == rhs.id
}
}
class FruitsViewController: UIViewController {
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Fruit>!
override func viewDidLoad() {
super.viewDidLoad()
title = "Diffable Fruits"
configureHierarchy()
configureDataSource()
applyInitialSnapshot()
}
// 2. Configure Collection View Layout
private func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
}
private func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 5
return UICollectionViewCompositionalLayout(section: section)
}
// 3. Configure the Diffable Data Source
private func configureDataSource() {
// Cell Registration: Define how to configure a cell for an item
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Fruit> { (cell, indexPath, fruit) in
var content = cell.defaultContentConfiguration()
content.text = "\(fruit.emoji) \(fruit.name)"
cell.contentConfiguration = content
cell.accessories = [.disclosureIndicator()]
}
// Data Source Initialization: Connects the collection view with your data
dataSource = UICollectionViewDiffableDataSource<Section, Fruit>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, fruit: Fruit) -> UICollectionViewCell? in
// Dequeue and configure the cell using the registration
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: fruit)
}
}
// 4. Apply an Initial Snapshot
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Fruit>()
snapshot.appendSections([.main])
snapshot.appendItems([
Fruit(name: "Apple", emoji: "๐"),
Fruit(name: "Banana", emoji: "๐"),
Fruit(name: "Orange", emoji: "๐"),
Fruit(name: "Grape", emoji: "๐")
], toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false) // 'false' for initial load, 'true' for updates
}
}
In this setup: We define Section and Fruit types that conform to Hashable. Fruit has a UUID to ensure uniqueness, even if two fruits have the same name. configureHierarchy() sets up a basic UICollectionView with a compositional layout. configureDataSource() is where the magic happens. We use UICollectionView.CellRegistration for modern cell configuration and then initialize our UICollectionViewDiffableDataSource, providing a closure that dequeues and configures cells. applyInitialSnapshot() creates an NSDiffableDataSourceSnapshot, adds sections and items, and then applies it to the dataSource.
Working with Snapshots
The real power of DiffableDataSource comes when your data changes. Instead of manually updating the collection view, you create a new snapshot representing the desired state and apply it.
Let's add some functionality to our FruitsViewController to update the data.
// ... inside FruitsViewController class ...
private var currentFruits: [Fruit] = [
Fruit(name: "Apple", emoji: "๐"),
Fruit(name: "Banana", emoji: "๐"),
Fruit(name: "Orange", emoji: "๐"),
Fruit(name: "Grape", emoji: "๐")
]
private var favoriteFruits: [Fruit] = []
// Modify applyInitialSnapshot to use currentFruits
override func viewDidLoad() {
super.viewDidLoad()
title = "Diffable Fruits"
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addRandomFruit))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Toggle Favorites", style: .plain, target: self, action: #selector(toggleFavoriteSection))
configureHierarchy()
configureDataSource()
applyCurrentStateSnapshot() // Renamed for clarity
}
// New method to apply the current state from our arrays
private func applyCurrentStateSnapshot(animatingDifferences: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Fruit>()
// Add main section
snapshot.appendSections([.main])
snapshot.appendItems(currentFruits, toSection: .main)
// Add favorites section if there are favorites
if !favoriteFruits.isEmpty {
snapshot.appendSections([.favorites])
snapshot.appendItems(favoriteFruits, toSection: .favorites)
}
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
@objc private func addRandomFruit() {
let newFruitNames = ["Kiwi", "Mango", "Pineapple", "Strawberry", "Cherry"]
let newFruitEmojis = ["๐ฅ", "๐ฅญ", "๐", "๐", "๐"]
let randomIndex = Int.random(in: 0..<newFruitNames.count)
let newFruit = Fruit(name: newFruitNames[randomIndex], emoji: newFruitEmojis[randomIndex])
currentFruits.append(newFruit)
applyCurrentStateSnapshot() // Apply the new snapshot with animation
}
@objc private func toggleFavoriteSection() {
if favoriteFruits.isEmpty {
// Move a fruit from main to favorites
if let fruitToMove = currentFruits.first {
currentFruits.removeFirst()
favoriteFruits.append(fruitToMove)
}
} else {
// Move a fruit from favorites back to main
if let fruitToMove = favoriteFruits.first {
favoriteFruits.removeFirst()
currentFruits.append(fruitToMove)
}
}
applyCurrentStateSnapshot()
}
// You might also want to add a header for sections
private func createLayout() -> UICollectionViewLayout {
// ... (existing item and group setup) ...
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 5
// Section Header
let sectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: sectionHeaderSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
section.boundarySupplementaryItems = [sectionHeader]
return UICollectionViewCompositionalLayout(section: section)
}
// Configure supplementary views (like headers)
private func configureDataSource() {
// ... (existing cell registration) ...
dataSource = UICollectionViewDiffableDataSource<Section, Fruit>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, fruit: Fruit) -> UICollectionViewCell? in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: fruit)
}
// Supplementary View Provider for headers
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) {
(supplementaryView, elementKind, indexPath) in
guard let section = self.dataSource.sectionIdentifier(for: indexPath.section) else { return }
var content = supplementaryView.defaultContentConfiguration()
content.text = (section == .main) ? "All Fruits" : "Favorite Fruits"
supplementaryView.contentConfiguration = content
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) -> UICollectionReusableView? in
return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
}
Now, when you tap "Add Random Fruit" or "Toggle Favorites", the applyCurrentStateSnapshot() method is called. It reconstructs the entire desired state of the collection view and asks the dataSource to update. The dataSource efficiently calculates the differences and animates the changes for you.
Here's a simple ASCII diagram illustrating the data flow:
โโโโโโโโโโโโโโโโโโโโโ
โ Your Data Model โ
โ (e.g., [Fruit]) โ
โโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ Create New โ
โ NSDiffableDataSourceSnapshot โ
โ (append sections/items) โ
โโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ dataSource.apply( โ
โ newSnapshot, โ
โ animatingDifferences: true โ
โ ) โ
โโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ Diffing Algorithm โ
โ (internal) โ
โโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ UICollectionView โ
โ Updates (e.g., โ
โ insert, delete, โ
โ reload, move) โ
โ with Animations โ
โโโโโโโโโโโโโโโโโโโโโ
Benefits and Best Practices
- Automatic Animations: By simply applying a new snapshot with
animatingDifferences: true, you get smooth, automatic animations for inserts, deletes, reloads, and moves, without any manual calculation of index paths. - State-Driven Updates: You describe what your UI should look like, not how to change it. This declarative approach makes your code easier to read, write, and maintain.
- Reduced Boilerplate: Gone are the days of implementing
numberOfSections,numberOfItemsInSection, andcellForItemAtalongside complex update logic. TheDiffableDataSourcehandles all of that. - Eliminates
NSInternalInconsistencyException: Since the diffing algorithm ensures the data source's state is always consistent with the UI, you largely avoid the notorious crashes caused by mismatched index paths. - Thread Safety: While
NSDiffableDataSourceSnapshotitself is immutable and can be constructed on any thread,dataSource.apply()must be called on the main thread. If you're performing heavy data processing, do it on a background queue, then dispatch the snapshot application to the main queue.
ItemIdentifierType and SectionIdentifierType Considerations
- Uniqueness is Key: The
Hashableconformance is critical. If yourItemIdentifierTypeorSectionIdentifierTypehas properties that can change, ensure yourhash(into:)implementation and==operator only consider the stable, unique identifier (like aUUIDor a database ID). If you hash mutable properties, changes to those properties will cause the item to be treated as a new item, leading to delete/insert animations instead of reload animations. - Structs vs. Enums: Both are excellent choices. Enums are great for a fixed number of sections or item types, while structs offer more flexibility for dynamic data.
UITableViewDiffableDataSource
It's worth noting that UITableViewDiffableDataSource works almost identically to its UICollectionView counterpart. The core concepts of NSDiffableDataSourceSnapshot, Hashable identifiers, and applying snapshots remain the same. If you understand one, you understand the other.
Summary
UICollectionViewDiffableDataSource is a game-changer for UIKit development. It simplifies complex data management, eliminates a common source of bugs, and provides beautiful, automatic animations with minimal effort. By embracing the state-driven approach of snapshots, you'll write cleaner, more robust, and more maintainable collection view code. If you're still using reloadData() or manual index path calculations, now is the perfect time to make the switch!
Happy Swifting!