Skip to content
academia.sh

Lesson 09 / 15

Table Views

A measurement of how a note of the previous row left in a reused cell counts as leftover, how a row whose model changed but was not reloaded counts as stale, and how which cell comes back is not measured.

Published Updated

In this lesson

  • Identifies that a table view hands the cell back through the reuse identifier
  • Measures a value left from the previous row in a reused cell as leftover
  • Measures a row whose model changed but was not reloaded as stale
  • Verifies that which cell comes back does not change the result and that what is measured is the last values written
Contents

In the previous lesson, Stack Views, we measured that removing something on screen means hiding it and that the code has to write the hiding. A table view carries another form of the same load. In a long list a separate view is not produced for every row; a cell that leaves the screen is reused for another row. If the cell is handed back without being cleaned, everything left over from the previous row stands on top of the new row.

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. A cell here is a Screen, and the same Screen object is rewritten from row to row; the model builds what the code writes to the screen, and it is not the framework itself.

The Reused Cell

Consider a contact list. Each row shows a name; next to favorite contacts a note is also shown. A table view asks for the cell with a reuse identifier. The cell may be newly produced or it may have been used before.

import UIKit

final class ContactCell: UITableViewCell {
    func configure(name: String, note: String?) {
        textLabel?.text = name
        if let note {
            detailTextLabel?.text = note
        }
    }
}

func cellForRow(_ tableView: UITableView, at indexPath: IndexPath, contact: Contact) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath) as! ContactCell
    cell.configure(name: contact.name, note: contact.note)
    return cell
}

configure(name:note:) writes the note only if there is one; if there is none, it leaves the field as it is. For this reason, if a row without a note inherits the cell of a row with a note, the old note stays on screen. The measurement has three steps: the first row is written, the cell is reused for the second row, and then the cell is cleaned.

// main.swift — the previous row's note left in a reused cell is leftover, out-of-scope once cleaned
let cell = Screen("ContactCell")
let rowOne = ["name": "Ayse", "note": "Favorite"]
let rowTwo = ["name": "Mehmet"]

cell.set("name", "Ayse")
cell.set("note", "Favorite")
let first = check("row-one", model: rowOne, screen: cell, keys: ["name", "note"])

cell.set("name", "Mehmet")
let reused = check("reused", model: rowTwo, screen: cell, keys: ["name", "note"])

cell.clear("note")
let cleaned = check("preparation-cleared", model: rowTwo, screen: cell, keys: ["name", "note"])

countVerdicts(first + reused + cleaned)
row-one | ContactCell.name: model=Ayse screen=Ayse -> matching
row-one | ContactCell.note: model=Favorite screen=Favorite -> matching
reused | ContactCell.name: model=Mehmet screen=Mehmet -> matching
reused | ContactCell.note: model=- screen=Favorite -> leftover
preparation-cleared | ContactCell.name: model=Mehmet screen=Mehmet -> matching
preparation-cleared | ContactCell.note: model=- screen=- -> out-of-scope
matching: 4
stale: 0
missing: 0
leftover: 1

In the row-one step both fields are matching. In the reused step name is matching because the name was rewritten; for note the model is -, the screen is Favorite and the result is leftover. The new row’s model has no note, but the cell carries the previous row’s note. In the preparation-cleared step, once the field is cleared, the row prints out-of-scope and is not counted.

The count covers five of the six rows: four matching, one leftover. The step where the cell is reused is the only place where the leftover is born.

LC1. This measurement is for a sequence of two rows. No general rule was claimed that a cell must be cleaned on every reuse; what is measured is what the code leaves on the screen.

On the SOURCE side, the cell itself can do the cleaning. Just before handing the cell back from the queue, the table view calls its prepareForReuse() method; by overriding this method, state unrelated to the content is reset, while the content is rewritten every time in cellForRowAt.

import UIKit

extension ContactCell {
    override func prepareForReuse() {
        super.prepareForReuse()
        detailTextLabel?.text = nil
    }
}

The preparation-cleared step of the previous measurement represents the result of this cleaning: the note field is emptied, and since the model carries no note either, the leftover disappears.

The Model Changes, the Row Is Not Refreshed

A wrongly kept cell is not the only cause. Sometimes the cell is configured correctly and the model changes afterward. A table view does not know by itself that the model changed; the reload of the row has to be called.

import UIKit

func contactRenamed(_ tableView: UITableView, row: Int) {
    tableView.reloadRows(at: [IndexPath(row: row, section: 0)], with: .automatic)
}

If reloadRows(at:with:) is called here, the table asks again for the cell of that row and configure runs once more. If it is not called, the cell keeps showing the old name. The measurement builds these two cases as separate steps.

// main.swift — the model changes the row name, it stays stale until the row is reloaded
let cell = Screen("ContactCell")
var model = ["name": "Mehmet"]

cell.set("name", "Mehmet")
let before = check("row-showing", model: model, screen: cell, keys: ["name"])

model["name"] = "Mehmet Ali"
let stale = check("model-changed", model: model, screen: cell, keys: ["name"])

cell.set("name", "Mehmet Ali")
let reloaded = check("row-reloaded", model: model, screen: cell, keys: ["name"])

countVerdicts(before + stale + reloaded)
row-showing | ContactCell.name: model=Mehmet screen=Mehmet -> matching
model-changed | ContactCell.name: model=Mehmet Ali screen=Mehmet -> stale
row-reloaded | ContactCell.name: model=Mehmet Ali screen=Mehmet Ali -> matching
matching: 2
stale: 1
missing: 0
leftover: 0

In the first step it is matching; in the second step the model is Mehmet Ali, the screen is Mehmet and the result is stale. In the third step the row was reloaded and it is matching. The count is two matching, one stale. Reuse produced leftover, and when the reload was skipped the result was stale: two different breaks in the same list, two different categories.

LC2. This measurement covers a single cell and a single field. Several rows changing at the same time was not measured.

Cell Registration and Reuse

A table view can show hundreds of rows, but it builds only as many cells as fit on the screen at the same time. A cell that leaves the screen is not thrown away; it goes into a pool and is used again for a new row. For this reason the cell is obtained in two steps. First the cell class is registered with the table under a reuse identifier; then, on every row, the data source asks the pool for a cell with this identifier.

import UIKit

final class ContactListController: UITableViewController {
    var contacts: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "ContactCell")
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        contacts.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath)
        cell.textLabel?.text = contacts[indexPath.row]
        return cell
    }
}

There are two required data source methods: the one that says how many rows there are and the one that builds the cell of each row. Because the second is called every time, it has to write all the fields of the cell. A cell coming from the pool carries the values left from the previous row; if a field is not written, the old value shows.

Preparing the Cell

A cell returning to the pool gets a chance to clean itself. The cell class can override the prepareForReuse method to reset state unrelated to the content (selection, opacity, editing mode). The content itself is always rewritten in cellForRowAt; that is the safe place to erase the row’s leftover.

import UIKit

final class NoteCell: UITableViewCell {
    let noteLabel = UILabel()

    override func prepareForReuse() {
        super.prepareForReuse()
        noteLabel.text = nil
        accessoryType = .none
    }
}

Two fields are emptied here: the note text and the accessory icon. If a field is skipped, its value appears in the next row. Cleaning the cell and the assignment in the data source complement each other; relying on only one of them is not enough.

When the Data Changes, the Row Is Not Refreshed

A table view does not follow the content of the data source by itself. When the array changes, the screen stays the same until the table is told. There are two ways to tell it: reloadData reloads the whole table, and reloadRows(at:with:) reloads only the specified rows. If the name of a single row changed, the second is cheaper and preserves the scroll position. Adding or deleting rows is done with insertRows and deleteRows; calling these without changing the data array at the same time leads to an inconsistency error at run time.

Table View or Collection View

A table view is designed for single-column, vertically scrolling lists and brings list behaviors ready-made, such as headers, footers, editing mode and swipe-to-delete. The rows in a settings screen, a contact list or an order history are typical examples. If the items spread across several columns, scroll horizontally, or the row layout varies from item to item, a collection view is more suitable. A collection view also supports the list layout, so the difference between the two has gradually shrunk in newly built screens; but a table view still needs less code for simple lists.

The concept of reuse is the same in both. The cell comes from the pool, so the code that configures the cell has to write every field. The leftover rows of this lesson will meet you whichever view you choose.

Common Mistakes

  • Asking the pool for a cell without registering it. A request made with an unregistered identifier stops the app at run time.
  • Not writing every field of the cell. A cell coming from the pool carries the values of the old row; this is the leftover row in the first measurement of this lesson.
  • Keeping persistent state in the cell. A cell is part of the screen at that moment, not of the row; state such as being selected should be kept in the data source.
  • Doing heavy work inside the cell. During scrolling every new row calls cellForRowAt, and long-running work breaks fluidity.
  • Changing the data array and not telling the table. The name of the row changes, but the screen keeps showing the old name; this is the stale result of the second measurement.

Interpreting the Measured Result

The common point of the three measurements is that the cell and the data live separately. In the first measurement the cell carried the previous row’s note, and since the model held no note in that row, the result was leftover. Once the cell was cleaned, the row became out-of-scope, because there was no value on either side. In the second measurement the model changed but the table was not told, the screen stayed old, and the result was stale. In the third measurement, which cell came back was not measured: the same residue gives the same result. This shows that reuse is a silent behavior. There is no error, there is no warning; there is only an old value, and the only way to find it is to compare it with the model.

A practical rule can be drawn here: if the code that configures the cell does not write a field, it must empty that field. Every line that skips a field carries the risk of showing again the previous value of the cell coming from the pool, and because this risk appears only during scrolling, in a particular row order, it can slip past testing.

The Bounding Measurement

Reuse has a numeric side too: how many cells the table view keeps and which cell returns to which row. The measure of this lesson does not cover these. The queue size and which cell comes back are not measured. The model only builds two configurations of one cell in sequence and looks at the last values written.

To show this, we build the same residue in two different cells: one is the same cell, the other is a different cell. For the rule there is no difference between them.

// main.swift — which cell comes back is not measured, the same residue gives the same result
let model = ["name": "Mehmet"]
let sameCell = Screen("SameCell")
let otherCell = Screen("OtherCell")

sameCell.set("name", "Mehmet")
sameCell.set("note", "Favorite")
otherCell.set("name", "Mehmet")
otherCell.set("note", "Favorite")

let sameResult = check("same-cell", model: model, screen: sameCell, keys: ["name", "note"])
let otherResult = check("other-cell", model: model, screen: otherCell, keys: ["name", "note"])

countVerdicts(sameResult + otherResult)
same-cell | SameCell.name: model=Mehmet screen=Mehmet -> matching
same-cell | SameCell.note: model=- screen=Favorite -> leftover
other-cell | OtherCell.name: model=Mehmet screen=Mehmet -> matching
other-cell | OtherCell.note: model=- screen=Favorite -> leftover
matching: 2
stale: 0
missing: 0
leftover: 2

The result for the two cells is exactly the same: the name is matching, the note is leftover. The count is two matching, two leftover. Which cell the residue came from did not enter the output, because the model has no field that carries the identity of the cell. This is a limit: the model compares the final state of the screen, not which cell led to that state.

LC3. This measurement tests that two screen objects carry the same values. No number was written for how many cells the table view keeps in the queue.

Summary

  • A table view hands the cell back through the reuse identifier; the returning cell can carry the values of the previous row.
  • A value absent from the new row’s model but left in the cell is leftover; if it is cleaned while the cell is prepared, it falls out of scope.
  • If a row whose model changed is not reloaded, the screen stays stale.
  • Reuse produces leftover, a skipped reload produces stale; two separate breaks are in two separate categories.
  • The queue size and which cell comes back are not measured; the model compares the last values written.
  • This lesson’s agreement: leftover.

Next Step

A table view is a single-column list. In the next lesson a collection view arranges items in two dimensions, and when an item is added to the data the screen has to learn about it. In that lesson a new agreement will be seen: missing.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close