Write, review, or improve UIKit code following best practices for view controller lifecycle, Auto Layout, collection views, navigation, animation, memory management, and modern iOS 18–26 APIs. Use when building new UIKit features, refactoring existing views or view controllers, reviewing code quality, adopting modern UIKit patterns (diffable data sources, compositional layout, cell configuration), or bridging UIKit with SwiftUI. Does not cover SwiftUI-only code.
96
100%
Does it follow best practices?
Impact
96%
1.23xAverage score across 9 eval scenarios
Passed
No known issues
The UIKit Collection View stack underwent a radical modernization between iOS 13 and iOS 17. Diffable data sources, compositional layout, type-safe cell registration, and list configurations now form a cohesive, declarative toolkit that replaces both the old UICollectionViewFlowLayout/delegate-callback pattern and UITableView itself. This guide distills every production-critical pattern and pitfall across seven topic areas, with concrete ✅ correct and ❌ incorrect Swift examples throughout.
The diffable data source algorithm tracks items across snapshots by identity—determined by Hashable and Equatable conformance. Apple's explicit recommendation (WWDC 2021 session 10252, official sample code) is to populate snapshots with stable identifiers (e.g., UUID, database primary key), not full model objects.
When a snapshot stores the full struct with synthesized Hashable, changing any property makes the diff engine interpret the update as a delete-then-insert of a new item. The collection view loses selection state, running animations, and cell caches.
Recipe.ID, cell provider looks up full modelstruct Recipe: Identifiable {
let id: UUID
var title: String
var isFavorite: Bool
}
// Item identifier is Recipe.ID (UUID), not Recipe
var dataSource: UICollectionViewDiffableDataSource<Section, Recipe.ID>!
dataSource = .init(collectionView: collectionView) {
collectionView, indexPath, recipeID -> UICollectionViewCell? in
let recipe = RecipeStore.shared.recipe(for: recipeID)
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration, for: indexPath, item: recipe
)
}
// Build snapshot with IDs only
var snapshot = NSDiffableDataSourceSnapshot<Section, Recipe.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(recipes.map(\.id), toSection: .main)
dataSource.applySnapshotUsingReloadData(snapshot) // initial load// BAD — synthesized Hashable uses ALL stored properties
var dataSource: UICollectionViewDiffableDataSource<Section, Recipe>!
// Toggling recipe.isFavorite now causes a delete + insert animation
// instead of an in-place update. Selection is lost. reconfigureItems
// and reloadItems cannot work because identity is unstable.If you do store model structs directly, you must override both hash(into:) and == to use only the stable identifier. The Hashable contract requires that two values that compare equal must produce the same hash. Violating this crashes the diff engine.
// ✅ Correct custom conformance
struct Item: Hashable {
let id: UUID
var title: String
var count: Int
func hash(into hasher: inout Hasher) { hasher.combine(id) }
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
}// ❌ Incorrect — hash includes title, equality uses only id
struct Item: Hashable {
let id: UUID
var title: String
func hash(into hasher: inout Hasher) { hasher.combine(title) } // BAD
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
}
// Two items with same id but different titles will be == yet hash
// differently → violates Hashable contract → crash or undefined behaviorBUG_IN_CLIENT duplicate-identifier crashThe crash message BUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE__DUPLICATE_ITEM_IDENTIFIERS (or the assertion _identifiers.count == _items.count) fires when a snapshot contains two items that resolve to the same identity. Every item identifier in a snapshot must be unique.
Common triggers and fixes:
let unique = Array(Dictionary(grouping: items, by: \.id).compactMap(\.value.first))case loading can appear only once. Fix: case loading(UUID) so each placeholder is unique.// ❌ Crash: enum case used as placeholder without unique identity
enum CellItem: Hashable {
case loading
case content(Model)
}
let items = Array(repeating: CellItem.loading, count: 5) // 💥 duplicates
// ✅ Fix: give each placeholder a unique ID
enum CellItem: Hashable {
case loading(UUID)
case content(Model)
}
let items = (0..<5).map { _ in CellItem.loading(UUID()) }apply vs applySnapshotUsingReloadData, and reconfigure vs reload| Behavior | apply(animatingDifferences:) | applySnapshotUsingReloadData |
|---|---|---|
| Diffing | Always computes diff (iOS 15+) | No diff — full reset |
| Animation | Yes if true; still diffs if false on iOS 15+ | Never |
| Cell reuse | Cells for unchanged items stay in place | All cells discarded and re-dequeued |
| Selection state | Preserved for unchanged items | Lost |
| Best for | Incremental updates | Initial load, wholesale data replacement |
A critical behavioral change landed in iOS 15: calling apply(animatingDifferences: false) no longer acts as reloadData—it still diffs, just without animation. Use applySnapshotUsingReloadData when you genuinely want a full reset.
// ✅ Initial load — no diff needed, no animation desired
func loadInitialData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Recipe.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(allRecipeIDs)
dataSource.applySnapshotUsingReloadData(snapshot)
}
// ✅ Subsequent update — diff and animate
func recipesDidChange(_ updatedIDs: [Recipe.ID]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Recipe.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(updatedIDs)
dataSource.apply(snapshot, animatingDifferences: true)
}reconfigureItems vs reloadItems — the exact differenceBoth are mutating methods on NSDiffableDataSourceSnapshot (iOS 15+). reconfigureItems reuses the existing cell in place; reloadItems discards the cell and dequeues a fresh one.
| Aspect | reconfigureItems | reloadItems |
|---|---|---|
| Cell lifecycle | Existing cell returned from dequeue | New cell dequeued; prepareForReuse() called |
| Cell type can change | ❌ Must be the same registration | ✅ Can switch to a different cell class |
| Prefetched cells | Preserved | Discarded (wastes prefetch work) |
| Performance | Better — avoids dequeue overhead | Worse — full creation cycle |
| Running cell state | Preserved (animations, gestures) | Reset |
Apple's guidance (WWDC 2021): prefer reconfigureItems unless the cell type itself must change.
// ✅ Preferred: reconfigure for content-only updates
func recipeContentChanged(_ recipeID: Recipe.ID) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([recipeID])
dataSource.apply(snapshot, animatingDifferences: true)
}
// ✅ Required: reload when cell type changes (e.g., compact → expanded)
func switchCellType(for itemID: Recipe.ID) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems([itemID])
dataSource.apply(snapshot, animatingDifferences: true)
}apply() can be called from a background thread, but you must always use the same queue. Mixing main-queue and background-queue applies causes internal state corruption and assertion failures. The safest pattern is to always dispatch to DispatchQueue.main or use Swift concurrency's @MainActor.
UICollectionViewCompositionalLayout(sectionProvider:) accepts a closure called for each section whenever layout is needed (including on rotation and trait changes). This is the recommended approach for multi-section layouts.
// ✅ Section-provider with environment-adaptive columns
func createLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
guard let self,
let sectionKind = self.dataSource.sectionIdentifier(for: sectionIndex)
else { return nil }
switch sectionKind {
case .hero:
return self.createHeroSection()
case .featured:
let section = self.createCarouselSection()
section.orthogonalScrollingBehavior = .groupPaging
return section
case .list:
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .supplementary
return NSCollectionLayoutSection.list(
using: config, layoutEnvironment: environment
)
}
}
}The section provider closure can fire multiple times per layout pass. For complex sections with many nested groups and supplementary items, cache NSCollectionLayoutSection objects and invalidate only when data structure changes.
// ✅ Cache sections to avoid repeated object allocation
private var sectionLayoutCache: [Int: NSCollectionLayoutSection] = [:]
func createLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, env in
if let cached = self?.sectionLayoutCache[sectionIndex] {
return cached
}
let section = self?.buildSection(at: sectionIndex, environment: env)
self?.sectionLayoutCache[sectionIndex] = section
return section
}
}
// Invalidate on data changes that alter section structure
func dataDidChange() {
sectionLayoutCache.removeAll()
collectionView.collectionViewLayout.invalidateLayout()
}visibleItemsInvalidationHandlerOrthogonal scrolling enables horizontally-scrollable carousels inside a vertically-scrolling collection view—replacing the old "collection-view-inside-table-view-cell" hack with a single line:
section.orthogonalScrollingBehavior = .groupPaging
// Options: .continuous, .continuousGroupLeadingBoundary,
// .groupPaging, .groupPagingCentered, .pagingOrthogonal sections do not trigger UIScrollViewDelegate. To react to scroll position (e.g., for parallax or scale effects), use visibleItemsInvalidationHandler:
// ✅ Correct: use visibleItemsInvalidationHandler for scroll-driven effects
section.visibleItemsInvalidationHandler = { visibleItems, scrollOffset, environment in
let centerX = scrollOffset.x + environment.container.contentSize.width / 2
for item in visibleItems {
let distance = abs(item.frame.midX - centerX)
let scale = max(1.0 - distance / environment.container.contentSize.width, 0.75)
item.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}Compositional layout provides three supplementary types:
// ✅ Boundary supplementary (sticky header)
let headerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44)
)
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: "section-header",
alignment: .top
)
header.pinToVisibleBounds = true // sticky header
section.boundarySupplementaryItems = [header]
// ✅ Item-level supplementary (badge)
let badge = NSCollectionLayoutSupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .absolute(20), heightDimension: .absolute(20)
),
elementKind: "badge",
containerAnchor: NSCollectionLayoutAnchor(
edges: [.top, .trailing], fractionalOffset: CGPoint(x: 0.3, y: -0.3)
)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])
// ✅ Decoration item (section background) — registered on LAYOUT, not collectionView
let background = NSCollectionLayoutDecorationItem.background(
elementKind: "section-background"
)
section.decorationItems = [background]
layout.register(SectionBackgroundView.self, forDecorationViewOfKind: "section-background")CellRegistration eliminates string-based reuse identifiersUICollectionView.CellRegistration<Cell, Item> (iOS 14+) replaces the old register-then-dequeue dance with a single, type-safe construct. No string identifiers, no forced downcasting, no possibility of runtime mismatches.
// ✅ Modern: type-safe, no strings, no manual registration
private lazy var cellRegistration = UICollectionView.CellRegistration<PhotoCell, Photo> {
cell, indexPath, photo in
cell.imageView.image = photo.thumbnail
cell.titleLabel.text = photo.title
}
// In cell provider:
dataSource = .init(collectionView: collectionView) { [weak self] cv, indexPath, photo in
guard let self else { return nil }
return cv.dequeueConfiguredReusableCell(
using: self.cellRegistration, for: indexPath, item: photo
)
}// ❌ Legacy: string-based, type-unsafe, crash-prone
collectionView.register(PhotoCell.self, forCellWithReuseIdentifier: "PhotoCell")
// In cellForItemAt — string typos crash at runtime, forced cast is unsafe
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "PhotoCell", for: indexPath
) as! PhotoCellApple's documentation states explicitly: creating a registration inside the CellProvider closure creates a new registration on every call, defeating cell reuse and crashing on iOS 15+.
// ❌ WRONG — new registration per call → crash on iOS 15+
dataSource = .init(collectionView: collectionView) { cv, indexPath, item in
let reg = UICollectionView.CellRegistration<MyCell, Item> { cell, _, item in
cell.configure(with: item)
}
return cv.dequeueConfiguredReusableCell(using: reg, for: indexPath, item: item)
}
// ✅ CORRECT — store registration as a lazy property
private lazy var cellReg = UICollectionView.CellRegistration<MyCell, Item> {
cell, indexPath, item in
cell.configure(with: item)
}SupplementaryRegistration follows the identical pattern for headers and footers:
private lazy var headerReg = UICollectionView.SupplementaryRegistration<HeaderView>(
elementKind: "section-header"
) { [weak self] headerView, _, indexPath in
let section = self?.dataSource.snapshot().sectionIdentifiers[indexPath.section]
var config = UIListContentConfiguration.groupedHeader()
config.text = section?.title
headerView.contentConfiguration = config
}
dataSource.supplementaryViewProvider = { [weak self] cv, kind, indexPath in
guard let self else { return nil }
return cv.dequeueConfiguredReusableSupplementary(using: self.headerReg, for: indexPath)
}UICollectionLayoutListConfiguration — five appearances and a rich accessories API| Appearance | Visual description | Typical use |
|---|---|---|
.plain | Full-width cells, no rounding. Matches UITableView.Style.plain. | Contacts, search results |
.grouped | Rounded-corner groups on grouped background. Matches .grouped. | Settings, forms |
.insetGrouped | Inset rounded cards with margin from edges. Matches .insetGrouped. | Modern settings, detail forms |
.sidebar | Translucent background, rounded selection highlight. iPadOS sidebar. | UISplitViewController primary column |
.sidebarPlain | Like sidebar but without rounded selection highlight. | Secondary navigation panes |
Swipe actions are set on the layout configuration, not on a delegate method:
// ✅ Correct: configure swipe actions on the layout, not a delegate method
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.trailingSwipeActionsConfigurationProvider = { indexPath in
let delete = UIContextualAction(style: .destructive, title: "Delete") {
_, _, completion in
self.deleteItem(at: indexPath)
completion(true)
}
delete.image = UIImage(systemName: "trash")
return UISwipeActionsConfiguration(actions: [delete])
}
config.leadingSwipeActionsConfigurationProvider = { indexPath in
let pin = UIContextualAction(style: .normal, title: "Pin") {
_, _, completion in
self.pinItem(at: indexPath)
completion(true)
}
pin.backgroundColor = .systemOrange
return UISwipeActionsConfiguration(actions: [pin])
}UICollectionViewListCell.accessories accepts an array of UICellAccessory values. Each accessory has a displayed: parameter controlling visibility in editing vs. non-editing modes.
// ✅ Correct: configure accessories via UICellAccessory array
cell.accessories = [
.disclosureIndicator(), // trailing chevron
.checkmark(), // trailing checkmark
.delete(displayed: .whenEditing, actionHandler: { /* reveal swipe */ }),
.reorder(displayed: .whenEditing), // drag handle
.detail(displayed: .whenNotEditing, actionHandler: { /* info tap */ }),
.multiselect(displayed: .whenEditing), // circular checkbox
.outline(displayed: .always, // expand/collapse
options: .init(style: .header)),
.label(text: "99+") // trailing badge label
]// ✅ Correct: separator and header configuration on the layout
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .supplementary // or .firstItemInSection
config.footerMode = .supplementary
config.showsSeparators = true
// Per-item separator control
config.itemSeparatorHandler = { indexPath, sectionConfig in
var c = sectionConfig
if indexPath.item == 0 { c.topSeparatorVisibility = .hidden }
c.bottomSeparatorInsets = .init(top: 0, leading: 60, bottom: 0, trailing: 0)
return c
}For self-sizing to engage, both the item and its containing group must use .estimated() for the self-sizing axis. Using .fractionalHeight(1.0) on the item while the group uses .estimated() creates a circular dependency that silently breaks self-sizing.
// ✅ Correct: both item and group use .estimated for height
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(80)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(80)
)
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])// ❌ Incorrect: item says "be 100% of group height" — circular
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0) // BAD — circular with group's .estimated
)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(80)
)Every custom self-sizing cell needs an unambiguous vertical constraint chain from contentView.topAnchor through all content down to contentView.bottomAnchor. Missing the bottom anchor is the single most common self-sizing bug.
// ✅ Correct: complete top-to-bottom chain in contentView
class DynamicCell: UICollectionViewCell {
private let titleLabel = UILabel()
private let bodyLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
bodyLabel.numberOfLines = 0 // essential for multi-line
[titleLabel, bodyLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview($0) // contentView, not self
}
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
bodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
bodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
bodyLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12)
// ↑ this bottom constraint completes the chain
])
}
required init?(coder: NSCoder) { fatalError() }
}// ❌ Incorrect: missing bottom constraint — cell collapses to zero height
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
// ... leading, trailing constraints ...
bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
// ... leading, trailing constraints ...
// NO bottom constraint — contentView cannot compute its height
])Other common mistakes that break self-sizing:
self instead of contentViewnumberOfLines at its default of 1 on labels expected to wrap.absolute() or .fractionalHeight() dimensions instead of .estimated()When using UICollectionViewListCell with UIListContentConfiguration, self-sizing is automatic—the content configuration handles the entire internal layout. No manual constraints are needed.
// ✅ UIListContentConfiguration — self-sizing is free
let reg = UICollectionView.CellRegistration<UICollectionViewListCell, Item> {
cell, indexPath, item in
var content = cell.defaultContentConfiguration()
content.text = item.title
content.secondaryText = item.subtitle
content.image = UIImage(systemName: "star")
cell.contentConfiguration = content
// No constraints needed — self-sizing just works
}Apple explicitly recommends UICollectionView with list configuration for all new list-based UI (WWDC 2020). UITableView is not deprecated but receives no new API investment—every modern list feature (compositional layout, section snapshots, cell registration, rich accessories) is exclusive to UICollectionView.
Step 1 — Replace the view and layout:
// Before
let tableView = UITableView(frame: .zero, style: .insetGrouped)
// After
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: config)
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)Step 2 — Replace cell registration and configuration:
// ❌ Before (deprecated textLabel API)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
// In delegate:
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = item.title // deprecated
cell.detailTextLabel?.text = item.detail // deprecated
cell.imageView?.image = item.icon // deprecated
cell.accessoryType = .disclosureIndicator
// ✅ After (modern content configuration)
let reg = UICollectionView.CellRegistration<UICollectionViewListCell, Item> {
cell, indexPath, item in
var content = cell.defaultContentConfiguration()
content.text = item.title
content.secondaryText = item.detail
content.image = item.icon
cell.contentConfiguration = content
cell.accessories = [.disclosureIndicator()]
}Step 3 — Replace data source:
// Before
UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { tv, ip, item in ... }
// After
UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { cv, ip, item in
cv.dequeueConfiguredReusableCell(using: reg, for: ip, item: item)
}Step 4 — Replace delegate:
// Before: UITableViewDelegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
// After: UICollectionViewDelegate
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)Step 5 — Replace background customization:
// Before
cell.backgroundColor = .systemBackground
cell.selectedBackgroundView = selectedView
// After — use UIBackgroundConfiguration exclusively
var bg = UIBackgroundConfiguration.listGroupedCell()
bg.backgroundColor = .systemBackground
cell.backgroundConfiguration = bg| UITableView | UICollectionView equivalent |
|---|---|
UITableView(style: .insetGrouped) | UICollectionViewCompositionalLayout.list(using: config) |
UITableViewCell | UICollectionViewListCell |
cell.textLabel?.text | content.text via UIListContentConfiguration |
cell.detailTextLabel?.text | content.secondaryText |
cell.imageView?.image | content.image |
cell.accessoryType | cell.accessories = [...] |
tableView(_:trailingSwipeActionsConfigurationForRowAt:) | config.trailingSwipeActionsConfigurationProvider |
UITableViewHeaderFooterView | SupplementaryRegistration with list header element kind |
tableView(_:heightForRowAt:) | .estimated() dimensions (self-sizing by default) |
cell.backgroundColor | cell.backgroundConfiguration |
| Single-style table | Mix list + grid + carousel sections in one view |
UICollectionView with list layout gives you everything UITableView offers plus the ability to mix list sections with grid and carousel sections in the same view, orthogonal scrolling, per-section layout customization, NSDiffableDataSourceSectionSnapshot for expandable/collapsible outlines, multi-column iPad sidebar support via .sidebar appearance, and a richer cell accessories system with functional accessories like .outline() and .multiselect(). The content configuration system (UIListContentConfiguration, UIBackgroundConfiguration) works identically in both UITableView and UICollectionView cells, making the migration of cell content straightforward.
reconfigureItems over reloadItems for in-place content updates. Compositional layout replaces flow layout and table view layout with a declarative, composable system where each section can have its own behavior, including orthogonal scrolling. Type-safe cell registration eliminates an entire class of string-based runtime crashes. And list configuration makes UICollectionView a strict superset of UITableView, rendering the latter unnecessary for new projects. Together, these APIs form a coherent, production-ready architecture that handles everything from simple settings screens to complex, multi-section feeds with heterogeneous layouts—all without a single reloadData() call or string-based reuse identifier.UICollectionViewDiffableDataSource — not legacy numberOfItemsInSection / cellForItemAtHashable implementation hashes and compares by ID only — not by mutable content fieldsBUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE crash)reconfigureItems used for content updates; reloadItems only when cell type changesapplySnapshotUsingReloadData used for initial population to bypass diff computationUICollectionView.CellRegistration used — no string-based register / dequeueReusableCellUICollectionViewCompositionalLayout used for non-trivial layouts — not FlowLayoutNSCollectionLayoutSection objects where possible.estimated() on both item AND group for the self-sizing axiscontentView