Collection Views & Diffable Data
Compositional layout, diffable data sources, cell registration, content configuration, prefetching
- 01
Scrolling a photo grid fast makes cells flash the previous row's image before settling — what is reuse doing, and where do you fix it?
EasyThe cell you dequeued is a recycled object that still holds the last row's image, and an older async load is finishing into it — cancel that work and clear the state in
prepareForReuse, then check identity when the load returns.final class PhotoCell: UICollectionViewCell { let imageView = UIImageView() private var loadTask: Task<Void, Never>? private var showingID: Photo.ID? // Reuse hands back the object, not a blank slate: whatever the last row left … - 02
In a compositional layout, what do item, group and section each contribute, and what does
.fractionalWidth(1.0)actually measure against?MediumAn item is one cell's slot, a group arranges items along an axis, a section applies scrolling and insets to a repeating group, and the layout holds the sections — and every fractional dimension is a fraction of the item's container, which is its group, not the screen.
// item -> group -> section -> layout. A group is itself an item, so groups nest. let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), // 1.0 of the GROUP, not of the screen heightDimension: .fractionalHeight(1.0))) item.contentInsets = .init(top: 6, leading: 6, bottom: 6, trailing: 6) // gutters live here … - 03
A horizontally paging carousel has to scale its centre card up as the user swipes — how do you build that inside a compositional layout?
MediumGive the section an
orthogonalScrollingBehaviorand do the scaling in itsvisibleItemsInvalidationHandler, which hands you every visible item plus the live offset on each frame of that scroll.func carouselSection() -> NSCollectionLayoutSection { let item = NSCollectionLayoutItem(layoutSize: .init( widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))) // 0.75 wide so the neighbouring cards peek in on both sides. let group = NSCollectionLayoutGroup.horizontal( … - 04
You need a rounded tinted card behind an entire section and a small badge hanging off a cell's corner — which layout objects give you those?
MediumA decoration item for the section background and an item-anchored supplementary item for the badge — the decoration never touches your data source, the badge does.
let badgeKind = "badge", backgroundKind = "section-background" // Anchored supplementary: edges plus a fractional offset put it outside the corner. let badge = NSCollectionLayoutSupplementaryItem( layoutSize: .init(widthDimension: .absolute(20), heightDimension: .absolute(20)), elementKind: badgeKind, … - 05
Design wants an inset-grouped table look with separators and swipe-to-delete, but the screen is already a collection view — what do you reach for?
MediumUICollectionLayoutListConfiguration— it turns a compositional layout, or a single section of one, into a UITableView-styled list with separators, swipe actions and accessories built in.private func makeLayout() -> UICollectionViewLayout { var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) config.headerMode = .supplementary config.separatorConfiguration.color = .separator // Stored on the layout for the screen's lifetime — a strong self leaks the VC. … - 06
Why did the team's new screens drop reuse identifiers for
CellRegistrationand outlets forcontentConfiguration?MediumBecause both remove a class of bug the string-and-outlet approach could not: the registration ties a cell type to an item type at compile time, and the configuration makes the cell's appearance a value you hand over rather than a set of properties you mutate.
// Created ONCE. Building a registration inside the cell provider makes a new one // per dequeue, so nothing is ever reused and scrolling falls apart. private lazy var rowRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Todo.ID> { [weak self] cell, _, id in guard let todo = self?.store[id] else { return } // Configurations are values: copy, mutate, assign back. Mutating what you read … - 07
A snapshot built from model structs animates every text edit as a delete plus an insert — what should the item type have been, and which queue may apply it?
MediumThe item type is an identity, not a value: with
Hashablesynthesized over every stored property, changing the title produces a different identifier, and the diff can only read that as one row leaving and another arriving.struct Message: Identifiable, Hashable { let id: UUID var text: String var isRead: Bool } … - 08
Apply crashes with "supplied item identifiers are not unique" — where do the duplicates come from, and how does the diff choose between a move and a reload?
MediumThe identifiers in one snapshot must form a set, and a struct with synthesized
Hashablemakes two rows that merely look alike the same identifier — that is where most duplicates come from.// Synthesized Hashable covers every stored property, so two rows that look alike // ARE the same identifier — and an edit to `title` changes the identity outright. struct Row: Hashable { let id: String var title: String var subtitle: String … - 09
Toggling a favourite star with
reloadItemsmakes the artwork flicker and drops the keyboard from a text field in the same row — what should you have called?Mediumsnapshot.reconfigureItems([id])— it re-runs your cell provider against the same live cell object, whilereloadItemsthrows that cell away and dequeues a fresh one.struct Song: Hashable { let id: UUID; var title: String; var isFavourite: Bool } // The snapshot carries ids only. If it carried Song values, flipping isFavourite // would change the identifier and every edit would become a delete plus an insert. private var store: [UUID: Song] = [:] private var dataSource: UICollectionViewDiffableDataSource<Int, UUID>! … - 10
A settings screen with collapsible groups springs every group back open after a refresh — where does the expansion state actually live?
MediumIn the
NSDiffableDataSourceSectionSnapshot, not in the cell and not in the flat snapshot — so a section snapshot you rebuilt from scratch is expanded exactly the way you just built it, and the refresh threw the user's choice away.enum Section { case settings } struct Row: Hashable { let id: String; let title: String } private var dataSource: UICollectionViewDiffableDataSource<Section, Row>! func applySettings(_ groups: [(header: Row, children: [Row])]) { … - 11
A feed of self-sizing cells jumps backwards as you scroll and the scroll indicator keeps changing size — what is causing the jump?
MediumEvery cell the collection view has not measured yet contributes its estimated height to the content size, and each time a real measurement replaces an estimate the total changes and everything below the finger shifts.
// A section that measures its cells: estimated group height, item fills the group. func makeLayout() -> UICollectionViewCompositionalLayout { let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))) let group = NSCollectionLayoutGroup.vertical( layoutSize: .init(widthDimension: .fractionalWidth(1), … - 12
The user flings past a hundred rows and image requests keep landing for cells that scrolled off ages ago — what did the prefetch code skip?
MediumcollectionView(_:cancelPrefetchingForItemsAt:)— without it every fling leaves the download and decode queues stuffed with work for index paths nobody will ever look at.@MainActor final class FeedViewController: UIViewController { private var dataSource: UICollectionViewDiffableDataSource<Int, Post.ID>! private var cache: [Post.ID: UIImage] = [:] // Keyed by the stable id. An IndexPath key cancels the wrong download the // first time a snapshot inserts or reorders anything. … - 13
A drag reorder looks correct until the next model refresh puts the row back where it started — what did the reordering handler miss?
HardThe collection view moved the cell and the data source moved the item inside its own snapshot, but nothing wrote the new order back to the model, so the next snapshot built from that model undoes the drag.
// The snapshot's item type is the id; the model is an order array plus a store. private var order: [Item.ID] = [] private var store: [Item.ID: Item] = [:] func configureReordering() { dataSource.reorderingHandlers.canReorderItem = { [weak self] id in … - 14
Compositional layout cannot express your staggered grid, so you subclass
UICollectionViewLayoutand scrolling crawls — which method is doing too much?HardlayoutAttributesForElements(in:)— it is called several times per frame, so a version that walks every item in the collection makes scrolling O(items) on every pass.final class StaggeredLayout: UICollectionViewLayout { var itemHeights: [CGFloat] = [] private let columns = 2 private var cache: [UICollectionViewLayoutAttributes] = [] // sorted by frame.minY private var contentHeight: CGFloat = 0 private var tallest: CGFloat = 0 … - 15
A live feed of ten thousand rows freezes for a beat on every socket update — where is
applyspending the time, and what do you change?HardIn hashing and comparing your item identifiers and then animating the changes it found — and
applyruns that diff on whatever queue you call it from, which is almost always the main one.// One serial queue owns the model, the snapshot build and the apply. Mixing this // with a main-queue apply is what produces the duplicate-identifier crash that // only ever reproduces under load. private let updates = DispatchQueue(label: "feed.updates") private var store = MessageStore() private var buffered: [Message] = [] … - 16
You inherit a 600-line
UITableViewDelegateand are told to move it onto a compositional list — what moves first, and how do you prove nothing regressed?HardExtract the row model first — whatever
cellForRowAtswitches on becomes the item identifier of a snapshot — and pin it down with a test over that pure state-to-items function before a single UIKit type changes.// STEP 1 — rows become data, and this function is the thing you actually test. enum Row: Hashable { case header(String), toggle(SettingKey), destructive } func items(for state: SettingsState) -> [Row] { var rows: [Row] = [.header(state.accountName)] rows += state.visibleKeys.map(Row.toggle) …