A fast, pure-Swift diffable data source
for UICollectionView
Hesham Salman · github.com/Iron-Ham/ListKit
Apple's diffable data source was a big step forward.
But the implementation is slow.
752x
faster
| Operation | ListKit | Apple | Speedup |
|---|---|---|---|
| Build 10k items | 0.002 ms | 1.223 ms | 752x |
| Build 50k items | 0.006 ms | 6.010 ms | 1,045x |
| Build 100 sections × 100 | 0.060 ms | 3.983 ms | 66x |
| Query itemIdentifiers 100× | 0.051 ms | 46.364 ms | 908x |
| Reload 5k items | 0.099 ms | 1.547 ms | 15.7x |
Release config • median of 15 • 5 warmup iterations • Apple Silicon
Two Swift modules. One package.
Objective-C class — heap allocated
Every item boxed to AnyHashable
Eagerly rebuilds internal hash maps on every mutation
Queries reconstruct results from map each time
Swift struct — stack / inline allocated
Generic <T: Hashable> — no boxing
Lazy reverse map — only built when needed
Queries return raw arrays directly
The data structure that makes everything fast
// The four fields of ListKit's snapshot
var sectionIdentifiers = [SectionID]()
var sectionItemArrays = [[ItemID]]() // parallel to sections
var sectionIndex = [SectionID: Int]() // section → position
var _itemToSection: [ItemID: SectionID]? // ← LAZY
sectionIdentifiers[i] and sectionItemArrays[i] always refer to the same section.
itemIdentifiers(inSection:) returns the raw array. No reconstruction.
deleteItems or insertItems(before:).
Paul Heckel, 1978 — six passes, O(n) average
The layer that makes real-world usage fast
1. Diff section arrays with Heckel
2. For each surviving section: skip if items unchanged
3. Diff items per section with Heckel
4. Reconcile cross-section moves
The skip in step 2 is why the no-change case is so dramatic:
Same algorithm. Different language, different architecture.
| Operation | IGListKit | ListKit | Speedup |
|---|---|---|---|
| Diff 10k (50% overlap) | 10.8 ms | 3.9 ms | 2.8x |
| Diff 50k (50% overlap) | 55.4 ms | 19.6 ms | 2.8x |
| Diff no-change 10k | 9.5 ms | 0.09 ms | 106x |
| Diff shuffle 10k | 9.8 ms | 3.2 ms | 3.1x |
Language: ObjC++ with message dispatch vs Swift generics with inlined hash/equality
Architecture: Flat array diff vs per-section diff with skip optimization
Moves: LIS minimization → fewer performBatchUpdates calls
Speed means nothing if it's painful to use
Define a view model:
struct Contact: CellViewModel, Identifiable {
typealias Cell = UICollectionViewListCell
let id: UUID
let name: String
func configure(_ cell: UICollectionViewListCell) {
var content = cell.defaultContentConfiguration()
content.text = name
cell.contentConfiguration = content
}
}
Use the DSL:
await dataSource.apply {
SnapshotSection("favorites") {
for contact in favorites {
contact
}
}
SnapshotSection("all") {
for contact in allContacts {
contact
}
}
}
UIKit — one object, done:
let list = SimpleList<Contact>(
appearance: .insetGrouped
)
view.addSubview(list.collectionView)
await list.setItems(contacts)
SwiftUI — modifier chain:
SimpleListView(
items: contacts,
appearance: .insetGrouped
)
.onSelect { contact in
navigate(to: contact)
}
.onDelete { contact in
remove(contact)
}
.onRefresh {
await reload()
}
Also: GroupedList for multi-section with headers/footers • OutlineList for hierarchical expand/collapse • MixedListDataSource for heterogeneous cell types
A subtle but important design decision
Did this item move, get inserted, or deleted?
Runs inside the O(n) diff — must be fast
Free via Identifiable
Same identity, but did the visible data change?
Runs only on matched pairs — after the diff
Triggers reconfigureItems automatically
Kept orthogonal by design. Neither is required. Both are opt-in.
Identifiable gives you identity-from-id for free.
ContentEquatable adds content-diff for free.
MIT License • iOS 17+ • Swift 6
Thank you