Lesson 10 / 15
Collection Views
A measurement of how an item added to the data source but not announced to the collection view counts as missing, how it counts as matching once a snapshot is applied, and how the position calculation is not measured.
Published Updated
In this lesson
- Distinguishes that a collection view takes its items from the data source and that an insertion is announced separately
- Measures an item added to the data source but not written to the screen as missing
- Shows that applying a snapshot brings added and removed items into agreement together
- Verifies that the position calculation of a flexible layout is not measured and that the model has no position key
Contents
In the previous lesson, Table Views, we measured that a reused cell can carry the trace of the previous row and that a row has to be reloaded separately when the model changes. A collection view carries the same idea into two dimensions: items are arranged in a grid or in another flexible layout. The measure of this lesson looks at the number of items. Whether the screen sees an item added to the data source is decided by a separate notification.
The measurement uses the core file.
// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
let name: String
private(set) var shown: [String: String] = [:]
init(_ name: String) { self.name = name }
func set(_ key: String, _ value: String) { shown[key] = value }
func clear(_ key: String) { shown[key] = nil }
}
func verdict(model: String?, shown: String?) -> String {
switch (model, shown) {
case (nil, nil): return "out-of-scope"
case (nil, _?): return "leftover"
case (_?, nil): return "missing"
case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
}
}
func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
keys.map { key in
let result = verdict(model: model[key], shown: screen.shown[key])
print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
return result
}
}
func countVerdicts(_ verdicts: [String]) {
for name in ["matching", "stale", "missing", "leftover"] {
print("\(name): \(verdicts.filter { $0 == name }.count)")
}
}
The core compares every field shown on screen with the model’s current value. In this lesson each item is a key: the identity of the item is the key and its title is the value. The model builds what the code writes to the screen; it is not the framework itself.
An Added Item May Not Reach the Screen
Consider a task board. The board shows the tasks in a collection view. The array of tasks stands in the data source; the collection view asks for the item count and the cells from there. When a task is added to the array, the view does not know it by itself.
import UIKit
final class BoardController: UIViewController, UICollectionViewDataSource {
var tasks = ["Report", "Test"]
@IBOutlet var collectionView: UICollectionView!
func addTask(_ title: String) {
tasks.append(title)
collectionView.insertItems(at: [IndexPath(item: tasks.count - 1, section: 0)])
}
}
addTask(_:) appends to the array and then calls insertItems(at:). Without that call, the collection view keeps working with the old item count. The measurement has three steps: the board opens, the model receives the third task, and the screen writes it.
// main.swift — an item added to the data source stays missing until it is announced
let board = Screen("TaskBoard")
var model = ["task1": "Report", "task2": "Test"]
board.set("task1", "Report")
board.set("task2", "Test")
let opened = check("opening", model: model, screen: board, keys: ["task1", "task2", "task3"])
model["task3"] = "Release"
let added = check("item-added", model: model, screen: board, keys: ["task1", "task2", "task3"])
board.set("task3", "Release")
let notified = check("addition-notified", model: model, screen: board, keys: ["task1", "task2", "task3"])
countVerdicts(opened + added + notified)
opening | TaskBoard.task1: model=Report screen=Report -> matching opening | TaskBoard.task2: model=Test screen=Test -> matching opening | TaskBoard.task3: model=- screen=- -> out-of-scope item-added | TaskBoard.task1: model=Report screen=Report -> matching item-added | TaskBoard.task2: model=Test screen=Test -> matching item-added | TaskBoard.task3: model=Release screen=- -> missing addition-notified | TaskBoard.task1: model=Report screen=Report -> matching addition-notified | TaskBoard.task2: model=Test screen=Test -> matching addition-notified | TaskBoard.task3: model=Release screen=Release -> matching matching: 7 stale: 0 missing: 1 leftover: 0
In the opening step task3 is absent on both sides and prints out-of-scope; the other two are matching. In the item-added step the model for task3 is Release, the screen is - and the result is missing: the data source knows the item, the screen does not. In the addition-notified step, once the item is written, all three rows are matching.
The count covers eight of the nine rows: seven matching, one missing. Missing is born here at the level of one item: a task that exists in the model and is not on the screen at all.
LC1. This measurement was made with three tasks, and the added item stands at the end. Adding the item in the middle of the array was not measured.
A Snapshot Writes Everything Together
Calling insertItems(at:) one by one requires mapping the indexes by hand once deletion and moving are added. A snapshot lifts this burden: the data source is given the new list of all items, and the view finds the difference itself.
import UIKit
func applyBoard(_ tasks: [String], to dataSource: UICollectionViewDiffableDataSource<Int, String>) {
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
snapshot.appendItems(tasks)
dataSource.apply(snapshot)
}
Here the insertion and the deletion reach the screen with a single call. In the measurement, let the model add one task and remove one task at the same time. Until the snapshot is written, neither has reached the screen. The helper function rewrites all the items on the screen according to the model.
// main.swift — a snapshot applies an addition and a removal together
func applySnapshot(_ items: [String: String], to screen: Screen) {
for key in screen.shown.keys.sorted() where items[key] == nil {
screen.clear(key)
}
for (key, value) in items.sorted(by: { $0.key < $1.key }) {
screen.set(key, value)
}
}
let board = Screen("TaskBoard")
let model = ["task2": "Test", "task3": "Release"]
board.set("task1", "Report")
board.set("task2", "Test")
let before = check("snapshot-before", model: model, screen: board, keys: ["task1", "task2", "task3"])
applySnapshot(model, to: board)
let after = check("snapshot-after", model: model, screen: board, keys: ["task1", "task2", "task3"])
countVerdicts(before + after)
snapshot-before | TaskBoard.task1: model=- screen=Report -> leftover snapshot-before | TaskBoard.task2: model=Test screen=Test -> matching snapshot-before | TaskBoard.task3: model=Release screen=- -> missing snapshot-after | TaskBoard.task1: model=- screen=- -> out-of-scope snapshot-after | TaskBoard.task2: model=Test screen=Test -> matching snapshot-after | TaskBoard.task3: model=Release screen=Release -> matching matching: 3 stale: 0 missing: 1 leftover: 1
Before, task1 has the model - and the screen Report, the result is leftover; task3 has the model Release, the screen -, the result missing; task2 is matching. After, task1 is absent on both sides, out-of-scope; task3 and task2 are matching. The count is three matching, one missing, one leftover. The same snapshot closed two different breaks together.
LC2. This measurement counts the applySnapshot helper as the screen side of the model; whether the real apply call ran animated or not animated was not measured.
Data Source and Snapshot
A collection view is more flexible than a table view: it shows an item instead of a row and can place items in a grid, in columns or in any layout the author designs. How the data is carried to the screen is done in one of two ways. The first is to write the count and cell methods as in a table; the second is to build a diffable data source.
A diffable data source states what will be on the screen with a snapshot. A snapshot is a value that carries the sections and the item identifiers in each section. When a new snapshot is applied, the data source calculates the difference between the old and the new and adds, deletes or moves only the changed items. In this way there is no need to call reloadData or to calculate row indexes by hand.
import UIKit
enum BoardSection {
case main
}
final class TaskBoardController: UIViewController {
var dataSource: UICollectionViewDiffableDataSource<BoardSection, String>!
func apply(titles: [String]) {
var snapshot = NSDiffableDataSourceSnapshot<BoardSection, String>()
snapshot.appendSections([.main])
snapshot.appendItems(titles)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
There are two type parameters: the section identifier and the item identifier. Both have to be Hashable. An identifier carries the identity of the item, not its value; for this reason two items sharing the same identifier lead to an error at run time. If the identifiers are not unique, the snapshot cannot be built.
Cell Registration
In a collection view the cell is registered with a registration object. The registration object declares how the cell is configured with a closure, and the data source uses this object in the cell provider. In this way the identifier strings disappear, and the problem of a misspelled identifier crashing at run time goes away too.
import UIKit
func makeDataSource(for collectionView: UICollectionView) -> UICollectionViewDiffableDataSource<BoardSection, String> {
let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, title in
var content = cell.defaultContentConfiguration()
content.text = title
cell.contentConfiguration = content
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, title in
collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: title)
}
}
The cell provider is called for every visible item and receives the item identifier. The path from the identifier to the content lies inside the closure; here the identifier is used directly as the title, but in a real application the identifier is the identifier of a model and the content is read from a separate source.
The Layout Is a Separate Object
How a collection view places its items is kept in the layout object. The data source and the layout are independent of each other: the same snapshot can be shown with both a grid layout and a list layout. The third measurement of this lesson verifies this; the grid board and the list board carry the same identifiers and the layout difference does not enter the output.
Choosing a Layout
The layout of a collection view determines how flexible the view will be. The flow layout lines items up row by row and is enough for fixed-size grids. The compositional layout gives a separate size description to sections, groups and items; screens of mixed shapes, horizontally scrolling strips and sections with headers are built with this layout. The list layout brings the behaviors of the table view to the collection view. The choice of layout does not affect the data source; the same snapshot can be shown with all three layouts.
This separation is the real gain of the snapshot approach. The data stays in one place, the form of the view in another, and the two do not know each other. Changing the layout of the screen does not require touching the data; changing the data does not require touching the layout.
Common Mistakes
- Using the identifier as a value. The displayed text of an item can change; the identifier must not change. A data source that uses the text as the identifier counts an item as deleted and added again when the text changes.
- Adding the same identifier twice. A snapshot does not accept a duplicate identifier.
- Building the snapshot from scratch every time and forgetting to add the items again. A missing item disappears from the screen.
- Applying a snapshot when the content of an existing item changes and expecting a difference to appear. When the identifier stays the same, the data source sees no difference; to refresh the content, a reload is requested separately.
- Mixing the layout and the data source in the same place. Writing layout information into the cell provider makes maintenance harder.
Interpreting the Measured Result
In the first measurement the third task entered the model but was not announced to the data source: in the item-added step the model was Release while the screen was empty and the row came out as missing. When the addition was announced, the same row became matching. In the second measurement two differences existed together: the first task had been removed from the model but stood on the screen, which was a leftover row; the third task was missing. After the snapshot was applied, the first task was absent on both sides and stayed out-of-scope, and the third task became matching. The count gave three matching, one missing and one leftover; only the state of the first step entered the count. The third measurement showed that the layout does not affect the agreement: the grid and the list gave the same four rows as matching, and the column count did not enter the output.
The three results lead to the same point. A diffable data source calculates the difference, but giving the snapshot, which is the input of the difference, at every change is the developer’s job. As long as no snapshot is applied, the model and the screen live apart: an added item stays missing and a deleted item stays leftover.
The practical rule is this: whenever the model changes, a new snapshot is built and applied. Tying the snapshot to the model change itself reduces the chance of forgetting; updating it in a separate place is the main source of the changes that leave the screen stale.
The Bounding Measurement
The essence of a collection view is the flexible layout: items can line up in three columns or in a single column. Which item falls on which row and column is the calculation of the layout. The position calculation of a flexible layout is not measured; the model has no position key, and the key is only the identity of the item.
Let two boards carry the same items: think of one as a three-column grid and the other as a single-column list. The column count is not a field in the model, so the two give the same result.
// main.swift — the column count is not in the model, two layouts give the same result
let model = ["task1": "Report", "task2": "Test"]
let grid = Screen("GridBoard")
let list = Screen("ListBoard")
for (key, value) in model.sorted(by: { $0.key < $1.key }) {
grid.set(key, value)
list.set(key, value)
}
let gridResult = check("grid", model: model, screen: grid, keys: ["task1", "task2"])
let listResult = check("list", model: model, screen: list, keys: ["task1", "task2"])
countVerdicts(gridResult + listResult)
grid | GridBoard.task1: model=Report screen=Report -> matching grid | GridBoard.task2: model=Test screen=Test -> matching list | ListBoard.task1: model=Report screen=Report -> matching list | ListBoard.task2: model=Test screen=Test -> matching matching: 4 stale: 0 missing: 0 leftover: 0
All four rows are matching and the count is four matching. The difference between the grid and the list did not enter the output. This is a limit: the model says whether an item is on the screen, not where it stands.
LC3. This measurement tests that two boards carry the same key set. No number was written for how the items are distributed across columns.
Summary
- A collection view takes its items from the data source; an insertion has to be announced separately.
- An item added to the data source but not written to the screen is missing.
- A snapshot applies an addition and a removal together; the earlier leftover and missing close in a single step.
- An item that never existed and is absent on both sides is out-of-scope.
- The position calculation of a flexible layout is not measured; the model only compares whether an item is on the screen.
- This lesson’s agreement: missing.
Next Step
Table views and collection views do not keep the data and the behavior themselves; they consult another object. In the next lesson this consulting relationship, the delegate pattern, will be covered. When a weakly held link falls away, the screen can stay stale.
To keep your progress and take notes, Log in
My notes
Log in to take notes.