Collection Views & Diffable Data
Irbisa · cheatsheetSeptember 13, 2026

Collection Views & Diffable Data

Compositional layout, diffable data sources, cell registration, content configuration, prefetching

Middle Developer16 itemscompressed for a skim
  1. 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?

    Easy

    The 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
    …
  2. 02

    In a compositional layout, what do item, group and section each contribute, and what does .fractionalWidth(1.0) actually measure against?

    Medium

    An 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
    …
  3. 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?

    Medium

    Give the section an orthogonalScrollingBehavior and do the scaling in its visibleItemsInvalidationHandler, 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(
    …
  4. 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?

    Medium

    A 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,
    …
  5. 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?

    Medium

    UICollectionLayoutListConfiguration — 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.
    …
  6. 06

    Why did the team's new screens drop reuse identifiers for CellRegistration and outlets for contentConfiguration?

    Medium

    Because 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
    …
  7. 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?

    Medium

    The item type is an identity, not a value: with Hashable synthesized 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
    }
    …
  8. 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?

    Medium

    The identifiers in one snapshot must form a set, and a struct with synthesized Hashable makes 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
    …
  9. 09

    Toggling a favourite star with reloadItems makes the artwork flicker and drops the keyboard from a text field in the same row — what should you have called?

    Medium

    snapshot.reconfigureItems([id]) — it re-runs your cell provider against the same live cell object, while reloadItems throws 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. 10

    A settings screen with collapsible groups springs every group back open after a refresh — where does the expansion state actually live?

    Medium

    In 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. 11

    A feed of self-sizing cells jumps backwards as you scroll and the scroll indicator keeps changing size — what is causing the jump?

    Medium

    Every 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. 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?

    Medium

    collectionView(_: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. 13

    A drag reorder looks correct until the next model refresh puts the row back where it started — what did the reordering handler miss?

    Hard

    The 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. 14

    Compositional layout cannot express your staggered grid, so you subclass UICollectionViewLayout and scrolling crawls — which method is doing too much?

    Hard

    layoutAttributesForElements(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. 15

    A live feed of ten thousand rows freezes for a beat on every socket update — where is apply spending the time, and what do you change?

    Hard

    In hashing and comparing your item identifiers and then animating the changes it found — and apply runs 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. 16

    You inherit a 600-line UITableViewDelegate and are told to move it onto a compositional list — what moves first, and how do you prove nothing regressed?

    Hard

    Extract the row model first — whatever cellForRowAt switches 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)
    …