Introducing

ListKit

A fast, pure-Swift diffable data source
for UICollectionView


github.com/Iron-Ham/ListKit

The Problem

Apple's diffable data source was a big step forward.
But the implementation is slow.

1.2 ms
Apple — build 10k items
0.002 ms
ListKit — build 10k items

752x
faster

The Full Picture

vs Apple's NSDiffableDataSourceSnapshot

OperationListKitAppleSpeedup
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

What Is ListKit?

Two Swift modules. One package.

Lists
SwiftUI Wrappers (SimpleListView, GroupedListView, OutlineListView)
Pre-Built Configs (SimpleList, GroupedList, OutlineList)
Data Sources + Builder DSL (ListDataSource, SnapshotBuilder)
depends on
ListKit
Snapshot (DiffableDataSourceSnapshot)
Diff Engine (HeckelDiff, SectionedDiff, DataSource)

Why Apple's Snapshot Is Slow

Apple's NSDiffableDataSourceSnapshot

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

ListKit's DiffableDataSourceSnapshot

Swift struct — stack / inline allocated

Generic <T: Hashable> — no boxing

Lazy reverse map — only built when needed

Queries return raw arrays directly

Parallel Array Storage

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.

The lazy map is the key. The common path — append sections, append items, apply — never builds the reverse map. It's only constructed on demand by mutation methods like deleteItems or insertItems(before:).

The Heckel Diff

Paul Heckel, 1978 — six passes, O(n) average

1 Scan new Build symbol table with occurrence counts
2 Scan old Same table, record old-side counts
3 Match uniques Unique in both → definite match (the key insight)
4 Expand forward Extend matches to adjacent equal elements
5 Expand backward Same, in reverse
6 Collect Unmatched old → delete, unmatched new → insert, changed order → move
+ LIS move minimization. After matching, a Longest Increasing Subsequence identifies items already in correct relative order. Only items outside the LIS need explicit move operations.

SectionedDiff

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:

9.5 ms
IGListKit — no change, 10k
0.09 ms
ListKit — no change, 10k
106x
faster

vs IGListKit

Same algorithm. Different language, different architecture.

OperationIGListKitListKitSpeedup
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

The API

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
        }
    }
}

Pre-Built Configs & SwiftUI

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

Two Kinds of Equality

A subtle but important design decision

Identity (Hashable)

Did this item move, get inserted, or deleted?

Runs inside the O(n) diff — must be fast

Free via Identifiable

Content (ContentEquatable)

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.

Takeaways

Data structures > algorithms.
ListKit and IGListKit use the same Heckel algorithm. The 2.8x gap is Swift value types vs ObjC objects, parallel arrays vs dictionaries, lazy maps vs eager indexing. The 752x snapshot gap is entirely data structure design.
The fastest code is code that doesn't run.
Per-section skip. Lazy reverse map. Structural-changes-only fast path. The biggest wins come from identifying work that can be skipped entirely.
Ergonomics don't have to cost performance.
Lists adds ViewModels, result builders, SwiftUI wrappers, and type erasure. AnyItem precomputes its hash at wrap time and short-circuits cross-type equality with a single pointer compare. You can have both.

ListKit

github.com/Iron-Ham/ListKit


MIT License • iOS 17+ • Swift 6


Read the deep dive on sundayswift.com →


Thank you