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.

NeedAPI
Scrollable list of rowsUITableView (or UICollectionView with list layout)
Grid, mosaic, magazine, custom layoutsUICollectionView with compositional layout
Reordering, deletes, animated updatesDiffable data source (universal)
Many sections with different layoutsCompositional 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:

  • UITableView is still simpler for vertical row lists with self-sizing
  • UICollectionView with UICollectionLayoutListConfiguration matches table view feature-for-feature
  • New code can pick either; teams often default to UICollectionView for 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; use URLCache or a library (Kingfisher, Nuke, SDWebImage).
  • reloadData() instead of snapshot diffs — kills animations, breaks scroll, can crash if data updates during a scroll.
  • prepareForReuse doing 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 UICollectionView per tab with many section types.
  • Instagram feed: UICollectionView with diffable data source; each post is a section with multiple item types (header, image, actions, caption). Used to be IGListKit; 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

  1. “Use reloadData() if performBatchUpdates is confusing.” Modern code uses neither; diffable data sources handle everything via snapshots.
  2. UICollectionView is overkill for a simple list.” With list configuration it’s the same code as a table, with better APIs going forward.
  3. “Compositional layout is hard.” It’s verbose at first; learn item → group → section once and the rest composes. Far simpler than the old flowLayout subclassing.
  4. “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).
  5. “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:

  1. Always identify items by Hashable ID (UUID, String), not by value. Lets the diffing engine track moves correctly.
  2. Use reconfigureItems over reloadItems when only content (not identity) changes. iOS 15+, much cheaper.
  3. Build cells with content configurations, not custom subclasses, when possible. UIListContentConfiguration is Apple’s tested, performant, accessible default.
  4. Profile with os_signpost any time you suspect collection view perf issues. Apple’s Instruments has built-in Collection View instruments.
  5. 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 self strongly 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