Choose a collection view whenever the screen is anything other than one vertical column of full-width rows (grids, carousels, mixed sections) and, since iOS 14, often for plain lists too, because a list configuration gives table-style rows inside it.
**UITableView: one column, built-in separators, section index, swipe actions and editing; still the simplest choice for classic lists.
UICollectionView**: layout is a separate UICollectionViewLayout, so the same data can be a grid, shelf or list; UICollectionLayoutListConfiguration gives table-style rows.
iOSMiddle
Collection Views & Diffable Data
Compositional layout, diffable data sources, cell registration, content configuration, prefetching
Questions closed
0 / 23
- 0Review
- 1Asked often
Questions
The cell is reused, and the code sets the badge and the red price only in the true branch: a cell that last showed a sale item keeps both when it is dequeued for a normal product. The title is fine because it is set on every path.
dequeueReusableCell returns a recycled cell carrying whatever its previous item left on it, so every data-dependent property must be assigned on every path:
Set both cases in one line each: cell.saleBadge.isHidden = !product.isOnSale and cell.priceLabel.textColor = product.isOnSale ? .systemRed : .label.
An if without an else inside cell configuration is the pattern to look for in review.
Resetting the badge in prepareForReuse hides the symptom too, but it splits one rule across two places; the configure step should describe the cell completely on its own.
deleteRows runs before the model shrinks: the table asks the data source for the new row count during the update, still gets the old one, and throws NSInternalInconsistencyException with "Invalid update: invalid number of rows in section 0".
Why: when deleteRows commits, the table checks that rows after = rows before − deleted + inserted, calling tableView(_:numberOfRowsInSection:) right then. notes still has the old count, the table expects one fewer, and the mismatch is thrown, not repaired.
Fix: change the model first, then tell the view: notes.remove(at:) before deleteRows. For several changes at once, make all the model edits inside the performBatchUpdates block together with the matching inserts and deletes.
Same rule elsewhere: insertRows, moveRow, insertSections and the UICollectionView equivalents check the same arithmetic.
Remove the class of bug: a diffable data source computes the changes from the snapshot, so there is no count to get out of sync.
Catch it: the crash is deterministic: a unit test that calls tableView(_:commit:forRowAt:) on a loaded controller reproduces it without any UI.
The dequeued cell is a recycled object that still shows the old image, and a request started for its previous item later finishes into it. Clear the image and cancel the load on reuse, and check which item the cell shows before setting a result.
Reuse recycles the view, not the data. Async loading adds a second problem: the completion captured the cell, not the item, so a slow response paints whatever row that cell is showing by then.
Clear at once: imageView.image = nil in prepareForReuse, or a placeholder in configure, so the old picture never shows for a frame. Call super.prepareForReuse() first.
Cancel: keep the Task on the cell and cancel it in prepareForReuse.
Check identity: a result can already be on its way when you cancel. Store the item id the load was started for and drop the image if the cell now shows something else. Never capture the IndexPath: it changes when items are inserted.
Cache and downsample: keep decoded thumbnails in an NSCache and decode at display size (byPreparingThumbnail(ofSize:) or ImageIO) instead of full-resolution photos; that is what keeps scrolling smooth.
The row index is copied into starButton.tag when the cell is configured, and insertRows does not reconfigure cells already on screen, so after a song arrives at the top, every visible star keeps its old index and toggles the song one row above.
Why: insertRows, deleteRows and moveRow shift the cells already on screen without calling cellForRowAt for them; only newly dequeued cells get a fresh tag. Any index stored in a cell (a tag, an indexPath captured in a closure) is a snapshot that goes stale on the next update.
Worse after a delete: the last visible cell still carries an index past the end of songs, and songs[sender.tag] traps with "Index out of range".
Fix: resolve the row at tap time: convert the button's origin into the table and ask indexPathForRow(at:), or call tableView.indexPath(for: cell). With a diffable data source, capture the item's stable id instead and look it up when the tap arrives.
addTarget in cellForRowAt is not the bug: UIControl ignores a target–action pair it already has, so a reused cell does not fire twice.
Catch it: a unit test that inserts a row, invokes the star action of the cell showing a known title and asserts which song changed; in review, treat tag = indexPath.row as a red flag.
Flow layout places items in a line and wraps to the next one, so a grid is just an item width computed from the available width: subtract the section insets and the gaps, divide by the column count, and round down.
Where sizes come from: itemSize on the layout for one fixed size, or collectionView(_:layout:sizeForItemAt:) from UICollectionViewDelegateFlowLayout per item.
Spacing is a minimum: minimumInteritemSpacing only sets the smallest gap; if the widths do not add up exactly, flow layout widens the gaps or wraps the last column.
Round down: rounding up can push the third item to the next row; floor the width.
Rotation and iPad: flow layout re-queries sizeForItemAt when the bounds size changes, so a width-derived size just works; call invalidateLayout() yourself only when an input to the calculation changed (for example the column count picked from the size class).
The storyboard trap: since Xcode 11 a flow layout in Interface Builder has Estimate Size set to Automatic, which turns on self-sizing and makes cells ignore sizeForItemAt. Set it to None.
When to move on: flow layout is one line-wrapping algorithm. Different layouts per section, horizontal shelves inside a vertical feed or self-sizing grids are what compositional layout (iOS 13+) is for. Flow layout is not deprecated and is fine for one simple grid.
Because you describe the whole state as a snapshot and UIKit works out the inserts, deletes and moves itself. reloadData has no animation and resets every visible cell; performBatchUpdates crashes as soon as your index-path arithmetic disagrees with the model.
**reloadData: reloads everything: no animation, visible cells reconfigured from scratch, self-sizing estimates recomputed. Right for a full reset, wrong for one changed row.
performBatchUpdates**: animated, but you compute every insertItems(at:) and deleteItems(at:) yourself, and the counts must match the data source afterwards or you get "Invalid update: invalid number of items in section 0".
Diffable: UICollectionViewDiffableDataSource plus an NSDiffableDataSourceSnapshot<Section, Item>: you append identifiers and call apply, and the diff produces the animated batch update. The snapshot is the single source of truth, so that crash class goes away.
What it costs: identifiers must be unique and Hashable, the cost of the diff grows with the item count and runs inside apply, and a content change under the same id has to be reported with reconfigureItems.
Available since iOS 13 and the default for new UIKit lists.
Only stable ids. The diff matches items by their Hashable identity, so when the struct is the identifier, editing any field creates a new identity (the row animates out and back in), and two rows with equal values become duplicates that crash the snapshot.
The diff compares identifiers per section: in both snapshots at a new position is a move, only in the new one is an insert, only in the old one is a delete, and in both at the same place is no change at all.
Edits become delete plus insert: synthesized Hashable covers every stored property, so changing title changes the identifier: the cell is replaced, selection is lost, the animation looks wrong.
Duplicates crash: two placeholder rows with identical values, the same id in two sections, or a paged API returning an item twice ends in "supplied item identifiers are not unique".
Mutable classes are worse: a class whose hash covers mutable state changes its hash while the snapshot holds it, and the next diff is nonsense.
The fix: put a UUID, a server id or Model.ID in the snapshot and keep the values in a store keyed by id that the cell registration reads. A value change at the same id is then invisible to the diff, so report it with reconfigureItems. A struct with custom ==/hash(into:) over the id alone also works, but then every content change must be reported with reconfigureItems.
snapshot.reconfigureItems([id]): it runs your cell registration again on the same cell that is on screen, while reloadItems throws that cell away and dequeues a fresh one.
**reloadItems**: handled like a removal and insertion of the same item: the old cell goes back to the reuse pool, prepareForReuse runs, and a new cell is configured from scratch. The decoded image, a running animation, first-responder status and a nested scroll offset are all lost, and self-sizing starts again from the estimate.
**reconfigureItems** (iOS 15+) involves no dequeue and no prepareForReuse: UIKit calls the registration handler with the existing cell, so only the values change. Selection, focus and in-flight work survive.
Existing items only: reconfiguring an id that is not in the snapshot is an error; a new row is appendItems.
Without diffable: the same pair exists on the view: collectionView.reconfigureItems(at:) and reloadItems(at:).
Needs a stable id: if the item identifier is the model struct, changing a field makes a new identifier and either call becomes a delete plus insert.
Rule of thumb: reload means "this is a different thing now", reconfigure means "same thing, new values"; almost every update from a live model is the second.
Because both remove bugs the old approach allowed: a registration ties a cell class to an item type at compile time, and a content configuration makes the cell's look a value you assign instead of properties you mutate.
Typed registration: UICollectionView.CellRegistration<Cell, Item> gives the handler a typed cell and item: no identifier string to mistype, no as! cast, no register call to forget. dequeueConfiguredReusableCell(using:for:item:) registers on first use.
Created once, pure handler: store it as a property or create it before the data source; the handler runs on every dequeue and on every reconfigureItems, so it should depend only on the item.
Configurations are values: defaultContentConfiguration() returns a UIListContentConfiguration (text, secondary text, image) already wired to Dynamic Type and dark mode. Copy it into a var, change it, assign it back; changing a copy alone does nothing on screen.
Look per state: set configurationUpdateHandler or override updateConfiguration(using:); UIKit calls it when selection, highlight, disabled or focus changes, so you describe the cell for a UICellConfigurationState instead of recoloring it in didSelectItemAt.
SwiftUI rows: since iOS 16 UIHostingConfiguration puts a SwiftUI view into a cell through the same contentConfiguration property.
On iOS 15 and later it throws on the first dequeue: UIKit detects a registration created inside the cell provider. Each call would create a new registration with its own reuse identifier, so no cell could ever be reused.
The exception message says it directly: creating a new registration every time a cell is requested prevents reuse and keeps the created cells in memory for the lifetime of the collection view. iOS 14 had no such check, so the same code shipped there and quietly grew memory as the user scrolled.
Fix: create the registration once, before the data source: a local let that the provider closure captures, or a stored let / lazy var on the controller. The provider then only calls dequeueConfiguredReusableCell(using:for:item:).
Same rule for headers: a SupplementaryRegistration created inside supplementaryViewProvider fails the same way.
The handler stays as it is: reading values by id from a store is the right pattern; only where the registration is created changes.
An item is one cell's slot, a group lines items up along an axis, a section repeats its group and adds insets and scrolling, and the layout holds the sections. A fractional size is always a fraction of the element's container: an item's group, a group's section.
That last rule is where people go wrong: a .fractionalWidth(1.0) item inside a .fractionalWidth(0.5) group is half the screen wide. A group is itself an item, so groups nest. A big tile beside two stacked small ones is a horizontal group holding an item and a vertical group.
**.fractionalWidth / .fractionalHeight**: a ratio of the container's side. Cross-axis works too: a group whose height is .fractionalWidth(0.5) with two items gives near-square tiles on any screen.
**.absolute / .estimated: fixed points (wrong for anything holding text) / a starting guess that UIKit corrects after measuring.
Spacing**: horizontal(layoutSize:repeatingSubitem:count:) divides the width evenly and accounts for interItemSpacing. With subitems: [item] the item's own width wins, so two .fractionalWidth(0.5) items plus spacing no longer fit and the second column disappears.
Adaptive layouts: the section-provider initializer receives an NSCollectionLayoutEnvironment; read container.effectiveContentSize.width or its traitCollection to choose the column count.
Until a cell is measured, the layout counts its estimated height in the content size. Each real measurement replaces an estimate, the total changes, and the content under the finger shifts; the further the estimate is from reality, the bigger the jump.
Opting in: in a compositional layout give both the item and the group an .estimated height. .absolute never measures.
A measurable cell: measurement runs Auto Layout on the contentView, so constraints must chain from its top edge to its bottom edge; without the bottom one the cell keeps its estimate. A multi-line UILabel also needs leading and trailing constraints, or it measures as one endless line.
Shrink the error: an estimate close to the real average makes the correction invisible.
Keep measurements: reconfigureItems keeps the cell and its size; reloadItems and reloadData start again from the estimate.
Resizing in place: since iOS 16 selfSizingInvalidation defaults to .enabled: a cell whose configuration changes resizes itself, and it can call invalidateIntrinsicContentSize() to ask. .enabledIncludingConstraints also reacts to constraint changes inside the cell.
UICollectionLayoutListConfiguration: it turns a compositional layout, or one section of it, into table-style rows with separators, headers, swipe actions and accessories.
Whole screen: UICollectionViewCompositionalLayout.list(using:).
One section among grids: NSCollectionLayoutSection.list(using:layoutEnvironment:) inside a section provider. This is why lists moved into collection views: section three becoming a carousel no longer means a rewrite.
Look: appearance is .plain, .grouped, .insetGrouped, .sidebar or .sidebarPlain; headerMode is .supplementary or .firstItemInSection; separatorConfiguration controls the lines.
Rows: UICollectionViewListCell: defaultContentConfiguration() gives the standard text-and-image layout, and accessories adds disclosure indicators, checkmarks, reorder grips and delete controls.
Swipe actions: trailingSwipeActionsConfigurationProvider (and its leading twin) returns a UISwipeActionsConfiguration.
Two traps in that provider. It receives an IndexPath, which can be stale by the time the action runs, so turn it into an identifier at once with dataSource.itemIdentifier(for:). And the action must apply a new snapshot itself; the row does not disappear on its own.
collectionView(_:cancelPrefetchingForItemsAt:): without it every fling fills the download and decode queues with work for items nobody will look at, and the visible rows wait behind it.
Setup: set collectionView.prefetchDataSource and implement UICollectionViewDataSourcePrefetching; prefetchItemsAt arrives ahead of display, roughly in scroll order.
A hint, not a contract: it is not called for the first screen and can be skipped under load, so the cell must still load on demand; prefetching only warms the cache.
Key by item id: index paths shift with every applied snapshot, and an index-path key cancels somebody else's download.
Cancel the real work: Task.cancel() stops URLSession's async calls, but your own decode loop has to check Task.isCancelled. Cancel in the cell's prepareForReuse too.
Not the same as cell prefetching: isPrefetchingEnabled (on by default) makes UIKit configure cells shortly before they appear; the prefetch data source is a separate hint for loading data further ahead. Code that assumes "configured means visible", such as analytics or autoplay, belongs in willDisplay.
Nothing stops a second request while the first is in flight. willDisplay fires for the last cell again (a bounce, a re-layout), both tasks read the same currentPage, and the second append adds identifiers already in the snapshot: "supplied item identifiers are not unique".
One request at a time: check an isLoading flag (or a stored Task) before starting, and keep a hasMore flag so the end of the feed stops asking.
Trigger early: use a threshold a few items before the end, or the prefetch data source, so the next page arrives before the user reaches the bottom.
Cursor, not page number: with offset pages, a new post on the server shifts page 2 so its first item repeats the last item of page 1. Ask for "items after X", and still remove ids you already have before appending.
Show the state: a loading footer and an error row with Retry. A failed request must not leave isLoading stuck, which defer guarantees.
Give each cell its own collection view and data source, pass the row's items in on configure, and save each row's horizontal offset by row id; otherwise reuse shows one row scrolled where another row was left, and a row you scrolled resets when it comes back.
Ownership: the cell owns the inner collection view and a small diffable data source; the controller only hands in data. Making the outer controller the inner data source and telling rows apart by tag breaks as soon as rows move.
Reuse: the inner view keeps its items and contentOffset when the cell is reused. Apply the new snapshot in configure with applySnapshotUsingReloadData (nothing to animate between unrelated rows), then restore the saved offset.
Remember offsets: store contentOffset.x keyed by row id in tableView(_:didEndDisplaying:forRowAt:) and pass it back when configuring.
Height: give the row a fixed height or a height constraint on the inner collection view: a scroll view has no intrinsic content size to self-size from.
On a new screen: skip the nesting: one compositional layout with orthogonalScrollingBehavior sections gives the same shelves with one data source and no nested reuse to manage.
UIKit moved the cell and the data source updated its own snapshot, but nothing wrote the new order back to your model, so the next snapshot built from the model puts the row back.
Enabling it: dataSource.reorderingHandlers.canReorderItem must return true for the item, and a list cell needs the .reorder() accessory to show a grip.
Persisting it: reorderingHandlers.didReorder receives an NSDiffableDataSourceTransaction; apply its difference to your order array with Array.applying(_:) and save the result right there, before anything rebuilds.
Classic data source: the same rule: collectionView(_:moveItemAt:to:) is where you move the element in your array.
Across sections or apps: that is full drag and drop: UICollectionViewDragDelegate returns UIDragItems, and UICollectionViewDropDelegate returns a UICollectionViewDropProposal and does the work in collectionView(_:performDropWith:).
A decoration item for the section background and an anchored supplementary item for the badge. The layout alone creates and places decorations; supplementaries are dequeued through your data source like cells.
Decoration: NSCollectionLayoutDecorationItem.background(elementKind:) in section.decorationItems draws behind that section's cells; its contentInsets pull the card in from the edges. The data source never sees it, so the view class is registered on the layout with register(_:forDecorationViewOfKind:); forget that and the app crashes the first time the section appears.
Headers and footers: NSCollectionLayoutBoundarySupplementaryItem in section.boundarySupplementaryItems; pinToVisibleBounds = true makes it sticky. Give it an .estimated height so it grows with Dynamic Type.
Anchored badge: NSCollectionLayoutSupplementaryItem(layoutSize:elementKind:containerAnchor:), passed to NSCollectionLayoutItem(layoutSize:supplementaryItems:). An NSCollectionLayoutAnchor with edges [.top, .trailing] and a fractionalOffset like (0.3, -0.3) hangs it partly outside the corner.
Providing the views: dataSource.supplementaryViewProvider must return a view for every kind the layout asks for; returning nil is an assertion failure, not an empty space.
Set the section's orthogonalScrollingBehavior to .groupPagingCentered and scale the cards in visibleItemsInvalidationHandler, which runs on every scroll frame of that section with the visible items and the current offset.
An orthogonal section scrolls inside a private scroll view that the layout creates. The outer collection view's contentOffset does not change while the carousel moves, and scrollViewDidScroll stays silent, so the handler is the only hook.
Behaviors: .continuous (free), .continuousGroupLeadingBoundary (free, settles on a group edge), .paging (pages the collection view's width), .groupPaging and .groupPagingCentered (one group per swipe; the centered one leaves the neighbours peeking).
Tuning: since iOS 17 section.orthogonalScrollingProperties sets decelerationRate and bounce.
Inside the handler: each NSCollectionLayoutVisibleItem has a frame plus settable transform, alpha and zIndex that apply without a layout pass. Map the distance from the item's midX to the visible center onto a scale.
Traps: never call invalidateLayout() from the handler, because it re-enters; keep the body allocation-free, since it runs up to 120 times a second.
layoutAttributesForElements(in:): UIKit calls it again and again while the user scrolls, so a version that computes frames or walks every item makes each scroll step cost O(items).
Where the work belongs
prepare(): compute every attribute once and cache it. It runs after an invalidation, not on every scroll step, and is the only place for real computation.
collectionViewContentSize: return the cached total; a wrong value breaks the scroll indicator and bouncing first.
layoutAttributesForElements(in:) is a lookup: keep the cache sorted by minY and binary-search the first candidate, or bucket attributes by row.
layoutAttributesForItem(at:) and the supplementary and decoration versions: one dictionary or array access each.
Invalidation is the other half
shouldInvalidateLayout(forBoundsChange:) returning true for every change re-runs prepare() on every scroll step. Return true only when the width changes.
For targeted updates, return a UICollectionViewLayoutInvalidationContext and mark only the affected items with invalidateItems(at:), so prepare() can rebuild part of the cache.
With self-sizing cells, shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) decides whether one measured cell forces a re-layout; return false when the size did not change.
The silent trap
layoutAttributesForElements(in:) must also return the attributes of headers, footers and decorations. Anything left out of that array is never shown, and nothing warns you.
Inside apply: it hashes and compares every identifier to find the changes, then animates each change, on the main thread. With heavy struct identifiers and one apply per socket message, that full diff repeats many times a second.
Where the time goes
Hashing: the cost of the diff grows with the item count, and the per-item cost is your Hashable. A twenty-field struct as the identifier hashes twenty fields ten thousand times; a UUID or String is cheap.
Animation: proportional to the number of changes. A thousand animated moves costs more than the diff that found them; animatingDifferences: false skips the animation but still diffs.
Frequency: ten messages a second means ten full diffs a second.
What to change
Coalesce: buffer incoming messages and apply at most once per ~100 ms.
Small ids, values in a store: and report content changes with reconfigureItems instead of creating new identifiers.
Skip the diff for wholesale changes: applySnapshotUsingReloadData(_:) for the first load or a new search: no animation, no diff.
Move your own work off main: parsing, merging and sorting the model can run in an actor; return the sorted id array and build the snapshot on the main actor, which for plain ids is cheap.
Which thread
Apple's documentation allows calling apply from a background queue only if every call uses that same queue; mixing queues causes duplicate-identifier crashes that appear only under load. Under Swift 6 strict concurrency the simplest rule the compiler can check is: apply on the main actor, and keep the expensive preparation away from it.
Extract the row model first (whatever cellForRowAt switches on becomes the item identifier of a snapshot) and cover that pure state-to-rows function with tests before any UIKit type changes.
The order that keeps each step reviewable
Row model: turn the implicit rows into an enum or id type and write func items(for state: State) -> [Row]. Unit-test it. This is the only step that touches logic, so it carries the risk.
Container swap: UICollectionViewCompositionalLayout.list(using:) with a UICollectionLayoutListConfiguration, plus a UICollectionViewDiffableDataSource over the row type. UIListContentConfiguration (defaultContentConfiguration()) replaces textLabel and detailTextLabel.
Delegate methods by kind: row heights disappear into the layout; didSelectRowAt becomes didSelectItemAt; swipe actions move to the list configuration's swipe providers; section titles become headerMode = .supplementary; willDisplay and didEndDisplaying keep their names.
**Remove reloadData**: last, once nothing calls it.
Proving it
The row-model tests from step 1: assert the exact item order for a handful of states.
Snapshot tests of the screen at the default text size, the largest accessibility size and a right-to-left language; list cells are not pixel-identical to hand-built ones, so review the diffs once and record new references.
UI tests for the interactions that lost their delegate method: select, swipe, reorder.
Ship the new screen behind a flag for one release and compare the same analytics events from both.
The traps
Index paths: a long delegate stores them in several places, and after a diff they are stale. Each becomes dataSource.itemIdentifier(for:) on the way in and indexPath(for:) on the way out. And do not redesign in the same pull request, because then no screenshot diff means anything.