4.5 — UITableView & UICollectionView
Opening scenario
The app you joined has a 600-line UITableViewController with a 90-line cellForRowAt, three if/else arms inside it, three prepareForReuse quirks, an Array re-sorted on every reloadData(), and intermittent “Cell at index path X doesn’t exist” crashes when filtering. Plus: the next ticket says “use the same UI but as a grid on iPad.”
In 2026 you do not write any of that. You use diffable data sources and compositional layouts — Apple’s modern APIs from iOS 13+ that solve the entire category of “I changed the data and the table state is inconsistent” bugs by design, and UITableView / UICollectionView are nearly interchangeable.
| Need | API |
|---|---|
| Scrollable list of rows | UITableView (or UICollectionView with list layout) |
| Grid, mosaic, magazine, custom layouts | UICollectionView with compositional layout |
| Reordering, deletes, animated updates | Diffable data source (universal) |
| Many sections with different layouts | Compositional layout sections |
Concept → Why → How → Code
Why UITableView exists when UICollectionView does more
Historical reasons. UITableView shipped in iOS 2; UICollectionView in iOS 6. By 2026:
UITableViewis still simpler for vertical row lists with self-sizingUICollectionViewwithUICollectionLayoutListConfigurationmatches table view feature-for-feature- New code can pick either; teams often default to
UICollectionViewfor consistency
For learning, you must know both. Production code: pick one and stay consistent within the codebase.
Diffable data sources — stop fighting state
Old world (don’t write this):
// ❌ ancient pattern
private var items: [Item] = []
func tableView(_ tv: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
func tableView(_ tv: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tv.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! ItemCell
cell.configure(items[indexPath.row])
return cell
}
func refresh(newItems: [Item]) {
items = newItems
tableView.reloadData() // throws away scroll position, breaks animations, racy
}
Modern world (write this):
import UIKit
enum Section: Hashable { case main }
final class ItemListVC: UIViewController {
private var tableView: UITableView!
private var dataSource: UITableViewDiffableDataSource<Section, Item.ID>!
private var items: [Item.ID: Item] = [:]
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
configureDataSource()
Task { await loadInitial() }
}
private func setupTableView() {
tableView = UITableView(frame: view.bounds, style: .plain)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.register(ItemCell.self, forCellReuseIdentifier: "Cell")
tableView.rowHeight = UITableView.automaticDimension
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item.ID>(tableView: tableView) {
[weak self] tv, indexPath, id in
let cell = tv.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! ItemCell
if let item = self?.items[id] { cell.configure(item) }
return cell
}
}
private func apply(_ newItems: [Item], animated: Bool = true) {
items = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
var snap = NSDiffableDataSourceSnapshot<Section, Item.ID>()
snap.appendSections([.main])
snap.appendItems(newItems.map(\.id))
dataSource.apply(snap, animatingDifferences: animated)
}
}
What you get for free:
- Inserts, deletes, moves between snapshots are diffed → correct animations automatically
- No “index out of bounds” race conditions between data update and reload
- Sections are first-class — append/insert/reorder
Use the item’s ID (Hashable) as the diffable identifier, not the full model. Otherwise changing any property forces a “delete then insert” instead of a reload.
For “the item’s data changed but it’s the same row” — call snap.reloadItems([id]) (animated diff between old & new contents) or snap.reconfigureItems([id]) (calls cell config without scrap/reuse — iOS 15+, preferred).
Cell registration — modern API
let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Item.ID> {
[weak self] cell, indexPath, id in
guard let item = self?.items[id] else { return }
var config = cell.defaultContentConfiguration()
config.text = item.title
config.secondaryText = item.subtitle
config.image = UIImage(systemName: item.iconName)
cell.contentConfiguration = config
cell.accessories = [.disclosureIndicator()]
}
dataSource = UICollectionViewDiffableDataSource<Section, Item.ID>(collectionView: collectionView) {
cv, indexPath, id in
cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)
}
No register(_:forCellWithReuseIdentifier:), no as! casts. Type-safe end to end.
For UITableView, the equivalent is UITableView.CellRegistration (iOS 17+). Same API shape.
Compositional layout — one layout for all the shapes
UICollectionViewCompositionalLayout is the way to build complex layouts in 2026. The model:
Section
├── Group (defines layout of items)
│ └── Item (defines size of a single cell)
└── Supplementary items (headers, footers, badges)
Vertical list:
let layout = UICollectionViewCompositionalLayout { sectionIndex, env in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60)
))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60)
),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
return section
}
Two-column grid:
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(0.5),
heightDimension: .fractionalHeight(1.0)
))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(120)
),
subitems: [item]
)
group.interItemSpacing = .fixed(8)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 8
section.contentInsets = .init(top: 16, leading: 16, bottom: 16, trailing: 16)
Horizontally scrolling carousel within a vertical list section:
section.orthogonalScrollingBehavior = .continuous // .paging, .continuousGroupLeadingBoundary, etc.
Apple Music’s UI: a single vertical scroll with multiple horizontal carousels — built entirely with compositional layout’s orthogonalScrollingBehavior. No nested collection views, no scroll delegation hacks.
For list-style sections that you’d previously do with UITableView:
let listConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let listSection = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: env)
Then your list section uses UICollectionViewListCell with its built-in swipe actions, accessories, etc.
Self-sizing cells
For variable-height cells (text-driven UI):
// UITableView
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 80
For compositional layout, use .estimated(60) instead of .absolute(60). The system measures the actual size after Auto Layout solves and updates the layout.
Inside cells, the constraint chain from contentView top → middle subviews → contentView bottom must be complete. If it’s broken, cells collapse to the estimated height.
Swipe actions
UICollectionViewListCell (or UITableViewCell in iOS 11+):
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
guard let id = self?.dataSource.itemIdentifier(for: indexPath) else { return nil }
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: "Delete") { _, _, completion in
self?.delete(id: id)
completion(true)
}
])
}
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .normal, title: "Star") { _, _, completion in
// mark item starred
completion(true)
}
])
}
Apple’s full-swipe behavior is handled automatically when the first action’s style = .destructive.
Performance — what to watch
UITableView / UICollectionView are highly optimized but still trip on:
- Heavy cells: lots of subviews, shadows without
shadowPath, blended layers. Profile with Core Animation instrument. - Synchronous image loading in
cellForRowAt. Always async; useURLCacheor a library (Kingfisher, Nuke, SDWebImage). reloadData()instead of snapshot diffs — kills animations, breaks scroll, can crash if data updates during a scroll.prepareForReusedoing too much — reset stateful properties only, not visual setup.- Cell heights that aren’t cached — provide accurate
estimatedRowHeight. Wildly wrong estimates make scroll position jump.
Section snapshots — for outline/expandable UIs
For nested/expandable hierarchies (Files-style outline view):
var sectionSnap = NSDiffableDataSourceSectionSnapshot<Item.ID>()
let parent = rootItem.id
sectionSnap.append([parent])
sectionSnap.append(rootItem.children.map(\.id), to: parent)
sectionSnap.expand([parent])
dataSource.apply(sectionSnap, to: .main, animatingDifferences: true)
Combined with UICollectionViewListCell.accessories = [.outlineDisclosure()] you get expand/collapse for free.
Drag & drop, reordering
dataSource.reorderingHandlers.canReorderItem = { _ in true }
dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
self?.applyReorder(transaction)
}
collectionView.dragInteractionEnabled = true
The system handles the gesture, animation, and snapshot diffing. You only react to the final transaction.
Headers, footers, decoration
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(40)),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
header.pinToVisibleBounds = true // sticky header
section.boundarySupplementaryItems = [header]
let headerRegistration = UICollectionView.SupplementaryRegistration<TitleHeaderView>(
elementKind: UICollectionView.elementKindSectionHeader
) { header, kind, indexPath in
header.titleLabel.text = "Section \(indexPath.section)"
}
dataSource.supplementaryViewProvider = { cv, kind, indexPath in
cv.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
In the wild
- Apple’s WWDC sample code — Search for “Modern Collection Views” (Apple’s official compositional-layout sample). Canonical patterns for every shape.
- App Store iOS app: every screen — Today, Apps, Games — is one compositional
UICollectionViewper tab with many section types. - Instagram feed:
UICollectionViewwith diffable data source; each post is a section with multiple item types (header, image, actions, caption). Used to beIGListKit; in 2026 modern compositional layout. - Apple Notes sidebar: list configuration with outline section snapshots for the folder hierarchy.
- Robinhood watchlist: custom compositional layout with sticky headers and continuous orthogonal scrolling carousels for the “top movers” rows.
Common misconceptions
- “Use
reloadData()ifperformBatchUpdatesis confusing.” Modern code uses neither; diffable data sources handle everything via snapshots. - “
UICollectionViewis overkill for a simple list.” With list configuration it’s the same code as a table, with better APIs going forward. - “Compositional layout is hard.” It’s verbose at first; learn item → group → section once and the rest composes. Far simpler than the old
flowLayoutsubclassing. - “I need a 3rd-party library for diffing.” Apple’s diffable data source is excellent; you only need a library for unusual cases (e.g., custom transitions).
- “Estimated sizes are exact.” They’re hints. The system measures actual cells; wildly wrong estimates affect scroll bar accuracy and initial scroll position.
Seasoned engineer’s take
Modern UIKit lists are easy once you commit to diffable + compositional. The old patterns (reloadData, hand-coded performBatchUpdates, flow layouts) generated a class of bugs that simply doesn’t exist with the new APIs. The flip side: senior interviewers will probe whether you know the modern stack — answering with the old patterns dates your knowledge to 2017.
Habits:
- Always identify items by
HashableID (UUID,String), not by value. Lets the diffing engine track moves correctly. - Use
reconfigureItemsoverreloadItemswhen only content (not identity) changes. iOS 15+, much cheaper. - Build cells with content configurations, not custom subclasses, when possible.
UIListContentConfigurationis Apple’s tested, performant, accessible default. - Profile with
os_signpostany time you suspect collection view perf issues. Apple’s Instruments has built-in Collection View instruments. - Test snapshots with multiple update orderings (insert + reload + delete in same snapshot). The diff engine is robust but your understanding might not be.
TIP: When converting old code, start by replacing the data source. Diffable + your existing layout works fine — you don’t need to migrate to compositional layout in the same PR. Incremental modernization beats big-bang rewrites.
WARNING: Don’t capture
selfstrongly in cell registration or supplementary registration closures. They’re long-lived (the registration object lives as long as the collection view). Always[weak self].
Interview corner
Junior-level: “How does cell reuse work?”
The collection/table view maintains a pool of cells off-screen. When a cell scrolls off, it’s added back to the pool. When a new row needs a cell, the pool’s reused. cellForRowAt gets a recycled cell — you must reset all stateful properties (image, text, selection) before configuring with the new data, otherwise old content bleeds through.
Mid-level: “What’s diffable data source and why is it better than reloadData()?”
You provide snapshots (immutable section/item lists by identifier). The data source diffs the new snapshot against the current state and applies inserts/deletes/moves with the right animations. Eliminates index-out-of-bounds bugs from racy mutations, gives correct animations free, makes sections first-class.
Senior-level: “Design a feed that mixes ad cards, story carousels, and post cells in one scroll with smooth 120fps performance.”
UICollectionView with compositional layout. Multiple section types: ad section (single full-width item with .estimated height), story section (horizontal orthogonal scrolling, items as .absolute(80) circles), post section (vertical list of estimated-height items). Diffable data source with an enum item type (case ad(AdID), story(StoryID), post(PostID)) so updates animate correctly when types interleave. Image loading via Nuke with prefetching tied to UICollectionViewDataSourcePrefetching. Cells preallocate views, avoid shadows without shadowPath, opaque backgrounds for blending. Profile on a low-end target (iPhone SE 3) with Time Profiler + Core Animation instruments to verify 120fps.
Red flag in candidates: Writing cellForRowAt with if/else to pick cell type, casting to a class with as!. The modern pattern is enum item identifiers + cell registrations per type.
Lab preview
Lab 4.1 builds a real diffable + table-style list. Lab 4.2 builds a 3-section compositional layout (banner, carousel, grid).
Next: 4.6 — User input