Irbisa · Cheatsheet

Collection Views & Diffable Data

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

Middle Developer · 23 questions

  1. 01

    When would you choose UICollectionView over UITableView?

    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.

  2. 02

    Products that are not on sale start showing the SALE badge after some scrolling: what is wrong with this code?

    override func collectionView(_ collectionView: UICollectionView,                             cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {  let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ProductCell",                                                for: indexPath) as! ProductCell  let product = products[indexPath.item]  cell.titleLabel.text = product.title  if product.isOnSale {    cell.saleBadge.isHidden = false    cell.priceLabel.textColor = .systemRed  }  return cell}

    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.

  3. 03

    Find the bug: swiping to delete a note crashes the app with NSInternalInconsistencyException.

    override func tableView(_ tableView: UITableView,                        commit editingStyle: UITableViewCell.EditingStyle,                        forRowAt indexPath: IndexPath) {  guard editingStyle == .delete else { return }  tableView.deleteRows(at: [indexPath], with: .automatic)  notes.remove(at: indexPath.row)  storage.save(notes)}

    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".

  4. 04

    Cells in a photo grid flash the previous item's image during fast scrolling: what causes it, and how do you fix it?

    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.

    final class PhotoCell: UICollectionViewCell {  let imageView = UIImageView()  private var loadTask: Task<Void, Never>?  private var photoID: Photo.ID?   override func prepareForReuse() {…
  5. 05

    Find the bug: after new songs arrive, tapping the star on a row sometimes favorites a different song.

    override func tableView(_ tableView: UITableView,                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {  let cell = tableView.dequeueReusableCell(withIdentifier: "Song", for: indexPath) as! SongCell  cell.titleLabel.text = songs[indexPath.row].title  cell.starButton.tag = indexPath.row  cell.starButton.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside)  return cell} @objc private func starTapped(_ sender: UIButton) {  songs[sender.tag].isFavorite.toggle()} func songArrived(_ song: Song) {  songs.insert(song, at: 0)  tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)}

    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.

  6. 06

    How do you build a three-column grid with UICollectionViewFlowLayout, and when do you switch to compositional layout?

    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.

    final class GridViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {  private static let spacing: CGFloat = 8  private let columns: CGFloat = 3   init() {    let layout = UICollectionViewFlowLayout()…
  7. 07

    Why use a diffable data source instead of reloadData or performBatchUpdates?

    Because you describe the whole state as a snapshot and UIKit works out the inserts, deletes and moves itself.

    enum Section { case main } final class ContactsViewController: UIViewController {  private var collectionView: UICollectionView!  private var dataSource: UICollectionViewDiffableDataSource<Section, Contact.ID>!  private var contacts: [Contact.ID: Contact] = [:]…
  8. 08

    Should a diffable snapshot hold your model structs or only their ids, and what breaks when it holds the structs?

    Only stable ids.

    struct Message: Identifiable, Hashable {  let id: UUID  var text: String  var isRead: Bool}…
  9. 09

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

    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.

  10. 10

    What do CellRegistration and contentConfiguration replace in a modern collection view, and what do they give you?

    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.

    private lazy var rowRegistration =  UICollectionView.CellRegistration<UICollectionViewListCell, Todo.ID> { [weak self] cell, _, id in    guard let todo = self?.store[id] else { return }    var content = cell.defaultContentConfiguration()   // a value: copy, change, assign    content.text = todo.title    content.secondaryText = todo.due.formatted(date: .abbreviated, time: .omitted)…
  11. 11

    This data source compiles without warnings: what happens when it runs, and how do you fix it?

    dataSource = UICollectionViewDiffableDataSource<Section, Contact.ID>(collectionView: collectionView) {  collectionView, indexPath, id in  let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Contact.ID> {    cell, _, id in    var content = cell.defaultContentConfiguration()    content.text = ContactStore.shared.name(for: id)    cell.contentConfiguration = content  }  return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)}

    On iOS 15 and later it throws on the first dequeue: UIKit detects a registration created inside the cell provider.

  12. 12

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

    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.

    func makeLayout() -> UICollectionViewCompositionalLayout {  UICollectionViewCompositionalLayout { _, environment in    let columns = environment.container.effectiveContentSize.width > 700 ? 4 : 2     // 1.0 of its GROUP, not of the screen.    let item = NSCollectionLayoutItem(layoutSize: .init(…
  13. 13

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

    Until a cell is measured, the layout counts its estimated height in the content size.

    func makeLayout() -> UICollectionViewCompositionalLayout {  // Both item and group estimated: this is what turns on measuring.  let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),                                    heightDimension: .estimated(120))  // near the real average  let item = NSCollectionLayoutItem(layoutSize: size)  let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])…
  14. 14

    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?

    UICollectionLayoutListConfiguration: it turns a compositional layout, or one section of it, into table-style rows with separators, headers, swipe actions and accessories.

    private func makeLayout() -> UICollectionViewLayout {  var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)   // Stored on the layout for the screen's lifetime: capture self weakly.  config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in    // Resolve the index path to an identifier now; it may be stale later.…
  15. 15

    The user flings past a hundred rows and image requests keep landing for cells that scrolled off long ago: what did the prefetch code skip?

    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.

    @MainActorfinal class FeedViewController: UIViewController, UICollectionViewDataSourcePrefetching {  private var dataSource: UICollectionViewDiffableDataSource<Int, Post.ID>!  private let images = ImageCache()  // Keyed by the stable id: index paths move with every snapshot.  private var tasks: [Post.ID: Task<Void, Never>] = [:]…
  16. 16

    This infinite-scroll code sometimes loads the same page twice and then crashes with a duplicate-identifier error: what is wrong, and how should it be built?

    func collectionView(_ collectionView: UICollectionView,                    willDisplay cell: UICollectionViewCell,                    forItemAt indexPath: IndexPath) {  guard indexPath.item == posts.count - 1 else { return }  Task {    let page = try await api.fetchPosts(page: currentPage + 1)    currentPage += 1    posts += page    var snapshot = dataSource.snapshot()    snapshot.appendItems(page.map(\.id))    await dataSource.apply(snapshot)  }}

    Nothing stops a second request while the first is in flight.

  17. 17

    How do you put a horizontally scrolling collection view inside a table view cell, and what goes wrong on reuse?

    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.

    final class ShelfCell: UITableViewCell {  let books = UICollectionView(frame: .zero, collectionViewLayout: ShelfCell.makeLayout())  // Created in init(style:reuseIdentifier:) with the cell's own registration.  private var dataSource: UICollectionViewDiffableDataSource<Int, Book.ID>!  private(set) var shelfID: Shelf.ID?…
  18. 18

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

    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.

    // The snapshot holds ids; 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…
  19. 19

    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?

    A decoration item for the section background and an anchored supplementary item for the badge.

    let badge = NSCollectionLayoutSupplementaryItem(  layoutSize: .init(widthDimension: .absolute(20), heightDimension: .absolute(20)),  elementKind: "badge",  containerAnchor: NSCollectionLayoutAnchor(edges: [.top, .trailing],                                            fractionalOffset: CGPoint(x: 0.3, y: -0.3)))let item = NSCollectionLayoutItem(…
  20. 20

    A horizontally paging carousel has to scale its center card up as the user swipes: how do you build that inside a compositional layout?

    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.

    func carouselSection() -> NSCollectionLayoutSection {  let item = NSCollectionLayoutItem(layoutSize: .init(    widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))   // 0.75 of the width, so the neighbouring cards peek in on both sides.  let group = NSCollectionLayoutGroup.horizontal(…
  21. 21

    You wrote a custom UICollectionViewLayout for a staggered grid and scrolling crawls: which method is doing too much?

    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).

    final class StaggeredLayout: UICollectionViewLayout {  var itemHeights: [CGFloat] = []  private let columns = 2  private let maxItemHeight: CGFloat = 400                      // upper bound for the search  private var cache: [UICollectionViewLayoutAttributes] = []    // sorted by frame.minY  private var byItem: [UICollectionViewLayoutAttributes] = []   // in item order…
  22. 22

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

    Inside apply: it hashes and compares every identifier to find the changes, then animates each change, on the main thread.

    @MainActorfinal class FeedController {  private var dataSource: UICollectionViewDiffableDataSource<Int, Message.ID>!  private let store = MessageStore()   // an actor: merging and sorting run off main  private var pending: [Message] = []  private var flushTask: Task<Void, Never>?…
  23. 23

    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?

    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.