Skip to content
academia.sh

Lesson 06 / 21

Lists and Forms

A measurement showing that a list reading the model's collection is recomputed with the observed trigger, that a form field bound to the parent's state is recomputed with the parent trigger, and that when one row's property changes the other rows are skipped.

Published Updated

In this lesson

  • Reads in a source snippet how a list builds its rows from a collection by identity
  • Measures that when the model's collection changes the list is recomputed with the observed trigger
  • Shows that a form field bound to the parent's state produces the parent trigger
  • Measures that when the property of a single row changes the other rows are not recomputed
Contents

In the previous lesson the grid’s data came from the parent and the number of columns from the environment. In this lesson we look at the source of the rows of a list: the collection usually lives in a model outside the tree. The list reads this collection and builds a row for each item. A form looks like a list, but the fields inside it bind their values to the state of a view above through a binding. The question stays the same: what triggered this recomputation? The trigger examined in this lesson is observed.

// Invalidation.swift — classifies which view a change recomputes and through which source
enum Storage {
    case state(owner: String)
    case model(object: String)
    case environment
}

struct ViewNode {
    let name: String
    let parent: String?
    let reads: Set<String>

    init(_ name: String, parent: String?, reads: Set<String>) {
        self.name = name
        self.parent = parent
        self.reads = reads
    }
}

struct Recompute {
    let view: String
    let cause: String
}

struct ViewTree {
    let nodes: [ViewNode]
    let storage: [String: Storage]

    func ancestors(of name: String) -> [String] {
        var found: [String] = []
        var current = nodes.first { $0.name == name }?.parent
        while let next = current {
            found.append(next)
            current = nodes.first { $0.name == next }?.parent
        }
        return found
    }

    func cause(of node: ViewNode, key: String) -> String {
        guard let kind = storage[key] else { return "out-of-scope" }
        switch kind {
        case .environment:
            return "environment"
        case .model:
            return "observed"
        case .state(let owner):
            if owner == node.name { return "local" }
            if ancestors(of: node.name).contains(owner) { return "parent" }
            return "off-rule"
        }
    }

    func change(_ label: String, key: String) -> [Recompute] {
        let hits = nodes.filter { $0.reads.contains(key) }
            .map { Recompute(view: $0.name, cause: cause(of: $0, key: key)) }
        let skipped = nodes.filter { !$0.reads.contains(key) }.map(\.name)
        let recomputed = hits.isEmpty ? "none" : hits.map { "\($0.view) \($0.cause)" }.joined(separator: ", ")
        let rest = skipped.isEmpty ? "none" : skipped.joined(separator: ",")
        print("\(label): \(key) changed | recomputed \(recomputed) | skipped \(rest)")
        return hits
    }
}

func countCauses(_ recomputes: [Recompute]) {
    for cause in ["local", "parent", "observed", "environment"] {
        print("\(cause): \(recomputes.filter { $0.cause == cause }.count)")
    }
}

The core is the same as in the opening lesson. The tree of this lesson is a notes inbox: the screen, a form, a list and two rows.

From Collection to Row

A list builds its rows from a collection whose items are told apart by identity. Identity ensures that an item corresponds to the same row even if its place in the collection changes. The snippet below binds a model’s collection to a list.

// Source snippet: a list from the model's collection
import SwiftUI
import Observation

@Observable
final class NoteStore {
    var notes: [Note] = []
}

@Observable
final class Note: Identifiable {
    let id: Int
    var title: String

    init(id: Int, title: String) {
        self.id = id
        self.title = title
    }
}

struct InboxList: View {
    let store: NoteStore

    var body: some View {
        List(store.notes) { note in
            Text(note.title)
        }
    }
}

store.notes is read in the body of the list; this is reading the value of an observed model. When the collection changes, that is, when a note is added or deleted, the body of the list is recomputed. Note is an @Observable class too: each note is an observed object of its own, and when a row reads note.title it binds to that note’s title. The model builds the framework’s dependency tracking rule; it is not the framework itself.

VS17. This measurement models the collection as a single notes key. Whether reordering an item of the collection and adding an item give different results was not measured.

List, Form and Row State

In the tree below the screen owns the draft title; the form field is bound to this value through a binding. The list reads the model’s collection. The title of the first row comes from that row’s Note object, while whether it is expanded or collapsed is the row’s own state.

// main.swift — the collection is observed, the form field is parent, the row state is local
let storage: [String: Storage] = [
    "notes": .model(object: "NoteStore"),
    "draftTitle": .state(owner: "InboxScreen"),
    "noteTitle1": .model(object: "Note1"),
    "noteTitle2": .model(object: "Note2"),
    "expanded1": .state(owner: "NoteRow1"),
    "expanded2": .state(owner: "NoteRow2"),
]

let screen = ViewTree(nodes: [
    ViewNode("InboxScreen", parent: nil, reads: ["draftTitle"]),
    ViewNode("NoteForm", parent: "InboxScreen", reads: ["draftTitle"]),
    ViewNode("InboxList", parent: "InboxScreen", reads: ["notes"]),
    ViewNode("NoteRow1", parent: "InboxList", reads: ["noteTitle1", "expanded1"]),
    ViewNode("NoteRow2", parent: "InboxList", reads: ["noteTitle2", "expanded2"]),
], storage: storage)

let recomputes = [
    screen.change("collection-changed", key: "notes"),
    screen.change("draft-typed", key: "draftTitle"),
    screen.change("row-expanded", key: "expanded1"),
].flatMap { $0 }

countCauses(recomputes)
collection-changed: notes changed | recomputed InboxList observed | skipped InboxScreen,NoteForm,NoteRow1,NoteRow2
draft-typed: draftTitle changed | recomputed InboxScreen local, NoteForm parent | skipped InboxList,NoteRow1,NoteRow2
row-expanded: expanded1 changed | recomputed NoteRow1 local | skipped InboxScreen,NoteForm,InboxList,NoteRow2
local: 2
parent: 1
observed: 1
environment: 0

In the collection-changed line only the list was recomputed with the observed trigger; the screen, the form and the rows were skipped. The rows read not the collection but their own titles. In the draft-typed line the screen is local and the form was recomputed with the parent trigger: the form field binds its value to the screen’s state, and when the screen’s state changes the field is recomputed too. In the row-expanded line only the first row was recomputed, with the local trigger.

Three changes produced three different triggers: observed, parent and local. When the model’s collection changed, a single view was recomputed; this is a consequence of the list being the only view that reads the collection.

VS18. This measurement covers notes, draftTitle and expanded1. Whether the screen or the field is recomputed first when a bound field changes the value was not measured.

Form Field and Binding

A form field needs a binding to be able to read and change its value. A binding gives a path to the owner of the value; the field writes the new value through this path. The snippet below binds a text field to the screen’s state.

// Source snippet: a form field is bound to the parent's state through a binding
import SwiftUI

struct InboxScreen: View {
    @State private var draftTitle = ""

    var body: some View {
        NoteForm(title: $draftTitle)
    }
}

struct NoteForm: View {
    @Binding var title: String

    var body: some View {
        Form {
            TextField("Title", text: $title)
        }
    }
}

The expression $draftTitle gives not the state itself but a path to it; the form takes this path as the title binding and passes it to the text field. As the field is typed into, the owner of the state is updated and the owner’s body is recomputed. In the model this is the draftTitle key being owned by the screen and read by the form.

The Bounding Measurement

In a list, when one row’s property changes, the other rows do not read that value. In the tree below the list reads the collection and each row reads only the title of its own Note object.

// main.swift — when a single row's title changes the other rows are skipped
let storage: [String: Storage] = [
    "notes": .model(object: "NoteStore"),
    "noteTitle1": .model(object: "Note1"),
    "noteTitle2": .model(object: "Note2"),
    "noteTitle3": .model(object: "Note3"),
]

let screen = ViewTree(nodes: [
    ViewNode("InboxList", parent: nil, reads: ["notes"]),
    ViewNode("NoteRow1", parent: "InboxList", reads: ["noteTitle1"]),
    ViewNode("NoteRow2", parent: "InboxList", reads: ["noteTitle2"]),
    ViewNode("NoteRow3", parent: "InboxList", reads: ["noteTitle3"]),
], storage: storage)

let recomputes = [
    screen.change("title-changed", key: "noteTitle2"),
    screen.change("note-added", key: "notes"),
].flatMap { $0 }

countCauses(recomputes)
title-changed: noteTitle2 changed | recomputed NoteRow2 observed | skipped InboxList,NoteRow1,NoteRow3
note-added: notes changed | recomputed InboxList observed | skipped NoteRow1,NoteRow2,NoteRow3
local: 0
parent: 0
observed: 2
environment: 0

In the title-changed line only the second row was recomputed with the observed trigger; the list and the other two rows were skipped. In the note-added line only the list was recomputed: because the titles of the rows did not change, no row was recomputed. This shows that a change in a row’s title does not require the list to be recomputed.

The counter shows two observed triggers and no other trigger. What makes the title a separate key is that Note is a class: writing a note’s title does not change the notes array, which keeps holding the same references to the same objects. For a large list this means the following: a write that updates one row affects only the view that reads that row.

VS19. This measurement models a list of three rows, each reading a single title key. The framework’s way of preserving row identity, the behavior of rows that are not visible during scrolling, and scale on large collections were not measured. If Note were a struct, a title write would count as a write to the notes array and the notes key would change in the tree; that modelling was not built here.

Summary

  • A list builds its rows from a collection whose items are told apart by identity.
  • When the model’s collection changes, the list that reads the collection is recomputed with the observed trigger.
  • A form field binds its value to the parent’s state through a binding; when the state changes, the field is recomputed with the parent trigger.
  • A row’s own expanded or collapsed state produces the local trigger.
  • When a row’s title changes, only the view that reads that row is recomputed; the other rows are skipped.
  • This lesson’s trigger: observed.

Next Step

In this lesson the trigger came from a model outside the tree. In the next lesson we look at visual components: what value does the color of an icon or a shape depend on. The answer is again a value that comes from the environment, this time the color scheme.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close