Skip to content
academia.sh

Lesson 01 / 21

Declarative Syntax

A measurement showing that six changes in a view tree produce eight recomputations through four different triggers, and that a value nobody tracks recomputes nothing.

Published Updated

In this lesson

  • Distinguishes a view derived from state in a declarative interface by comparing it with the imperative approach
  • Measures which views a change recomputes by looking at the values each view reads
  • Derives the four triggers of recomputation (local, parent, observed, environment) from where a value is stored
  • Shows that a view that does not read the changed value is skipped rather than recomputed
Contents

The previous course introduced the layers, frameworks and versions of the platform; this course moves from definition to change. In a declarative interface, the code that describes a screen does not say how to update the screen; it says what the screen looks like in a given state. When the state changes, the framework reads the description again, but it does not re-evaluate every view that wrote part of it, only the views that read the changed value. Every lesson in this course asks the same question: what triggered this recomputation? The trigger examined in this lesson is the distribution of all four: six changes, four triggers.

The heart of the measurement is the core file below. It does not change during the lesson; every lesson in the course gives it verbatim and builds its own view tree on top.

// 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 keeps two things apart. Storage says where a value is stored: a view’s own state (state), a property of an observable object (model) or an environment value (environment). ViewNode says what a view reads. Given a key, change finds the views that read that key and runs the cause function for each of them. The label does not supply the trigger; the rule does: an environment value counts as environment, an object property as observed, a view’s own state as local, and the state of an ancestor as parent. The model builds the framework’s dependency-tracking rule; it is not the framework itself.

Six Changes

The tree below builds a task screen: the screen itself, a header, a filter bar, a list, the list’s row and a progress badge. The values each view reads are in reads, and the place where each value is stored is in storage. Six changes are applied, and for each one the views that were recomputed are printed.

// main.swift — six changes, four triggers
let storage: [String: Storage] = [
    "filter": .state(owner: "TaskScreen"),
    "isExpanded": .state(owner: "TaskRow"),
    "tasks": .model(object: "TaskStore"),
    "completedCount": .model(object: "TaskStore"),
    "dynamicTypeSize": .environment,
    "colorScheme": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("TaskScreen", parent: nil, reads: ["filter"]),
    ViewNode("HeaderView", parent: "TaskScreen", reads: ["dynamicTypeSize"]),
    ViewNode("FilterBar", parent: "TaskScreen", reads: ["filter"]),
    ViewNode("TaskList", parent: "TaskScreen", reads: ["filter", "tasks"]),
    ViewNode("TaskRow", parent: "TaskList", reads: ["isExpanded", "colorScheme"]),
    ViewNode("ProgressBadge", parent: "TaskScreen", reads: ["completedCount"]),
], storage: storage)

let recomputes = [
    screen.change("filter-selected", key: "filter"),
    screen.change("row-expanded", key: "isExpanded"),
    screen.change("task-added", key: "tasks"),
    screen.change("task-completed", key: "completedCount"),
    screen.change("text-enlarged", key: "dynamicTypeSize"),
    screen.change("dark-mode", key: "colorScheme"),
].flatMap { $0 }

countCauses(recomputes)
filter-selected: filter changed | recomputed TaskScreen local, FilterBar parent, TaskList parent | skipped HeaderView,TaskRow,ProgressBadge
row-expanded: isExpanded changed | recomputed TaskRow local | skipped TaskScreen,HeaderView,FilterBar,TaskList,ProgressBadge
task-added: tasks changed | recomputed TaskList observed | skipped TaskScreen,HeaderView,FilterBar,TaskRow,ProgressBadge
task-completed: completedCount changed | recomputed ProgressBadge observed | skipped TaskScreen,HeaderView,FilterBar,TaskList,TaskRow
text-enlarged: dynamicTypeSize changed | recomputed HeaderView environment | skipped TaskScreen,FilterBar,TaskList,TaskRow,ProgressBadge
dark-mode: colorScheme changed | recomputed TaskRow environment | skipped TaskScreen,HeaderView,FilterBar,TaskList,ProgressBadge
local: 2
parent: 2
observed: 2
environment: 2

Reading the output line by line: in the filter-selected change, TaskScreen stores the value as its own state, so it is local; FilterBar and TaskList read the same value but do not own it, so they are parent. HeaderView and ProgressBadge do not read the filter and are skipped. In the row-expanded change only TaskRow is recomputed, because isExpanded is its own state; the rest of the screen is not even aware of the change. task-added and task-completed change properties of an object and count as observed. text-enlarged and dark-mode are environment values and count as environment.

The counts come out as follows: six changes produced eight recomputations; two are local, two are parent, two are observed, two are environment. The number of changes and the number of recomputations are not the same thing: the single filter-selected change recomputed three views. That the distribution came out even belongs to the choices made when building the tree, not to the rule; the lesson does not present it as a finding.

VS1. This measurement covers only six changes and six keys. A change of two values at the same time, and a change sequence in which a view reads both a state and an environment value, were not measured.

The Two Readers of the Filter

The parent label in the first line shows what the category looks at. FilterBar and TaskList do not own the filter value; the owner is TaskScreen, and the owner is among the ancestors found by ancestors. The category looks not at who started the change but at where the value is stored and who reads it. The filter bar receives the value through a binding: a reference to the screen’s state, not a copy of its own. The list receives the same value as a parameter.

// Source snippet: FilterBar takes the filter from its parent's state through a binding
import SwiftUI

struct FilterBar: View {
    @Binding var filter: String

    var body: some View {
        Picker("Filter", selection: $filter) {
            Text("All").tag("all")
            Text("Open").tag("open")
        }
        .pickerStyle(.segmented)
    }
}

struct TaskScreen: View {
    @State private var filter = "all"

    var body: some View {
        VStack {
            FilterBar(filter: $filter)
            Text("Filter: \(filter)")
        }
    }
}

TaskScreen stores the value with @State and produces the binding with $filter. Both views use filter in their bodies, which is why both have this key in their reads set in the tree. Whether a view is recomputed depends on whether it reads the value, not on its place in the tree: HeaderView is a child of the same screen but does not read the filter, so it is skipped. Being a child is not a dependency; reading is a dependency.

This snippet is source only as well. The model builds the framework’s reading rule; how the binding works, that is, how the owner’s state is updated when a value is written, was not measured in this lesson.

The Imperative Way and the Declarative Way

In an imperative interface layer, code propagates every change by hand: when the counter increases, you find the label and write its text. The snippet below shows this. Keeping the label current depends on the label.text line being written on every path that changes the count; if you forget one path, the screen goes stale.

// Source snippet: the imperative way, the label is updated by hand on every change
import UIKit

final class CounterController: UIViewController {
    private let label = UILabel()
    private var count = 0

    func increment() {
        count += 1
        label.text = "Count: \(count)"
    }
}

In the declarative way, the same screen is a description. The button’s text derives from count; count is marked as state with a property wrapper, which means the framework tracks it. The code does not write label.text; it only increases count, and the framework takes on recomputing the body (body).

// Source snippet: the declarative way, the view derives from state
import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Button("Count: \(count)") {
            count += 1
        }
    }
}

The difference between the two snippets lies not in code length but in where the responsibility sits. In the imperative way, the answer to “what should the screen look like now” is spread across every line that makes a change. In the declarative way, the answer is in one place, the body, and the body depends only on the values it reads. That is why the measure is which view reads which value: the dependency arises from the read inside the description. Both snippets are source only; they rely on the platform framework and no output is claimed.

VS2. This section only compares the two counter snippets. The cases in which the imperative way takes less code than the declarative way, and the runtime cost of the two ways, were not measured.

The Body Is a Function of State

Writing the TaskRow view of the measured tree as real code shows where the reads set comes from. In the snippet below, the body reads two things: the view’s own state isExpanded and colorScheme from the environment. The two reads correspond to the reads: ["isExpanded", "colorScheme"] line in the tree.

// Source snippet: the TaskRow body reads its own state and an environment value
import SwiftUI

struct TaskRow: View {
    let title: String
    @State private var isExpanded = false
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
            if isExpanded {
                Text("Detail")
            }
        }
        .foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
        .onTapGesture { isExpanded.toggle() }
    }
}

title is a constant and is not a key in the tree: the view cannot change it itself, and if the parent builds a new TaskRow with a new value, the view’s body is recomputed. In the measured model, the values the parent supplies turn into the parent trigger when they depend on the parent’s state; TaskList reading filter is such a case. The model builds this rule of the framework: a view depends on the value it reads, not on the value it does not read.

This is why five views were skipped in the row-expanded line. Expanding a row does not concern the screen’s header, the filter bar, the list or the badge; none of them reads isExpanded. This is the return of a declarative interface: instead of rebuilding the whole screen on every change, it recomputes the places where the changed value is read. The dependency arises from the read inside the description.

The Bounding Measurement

Not every value produces a trigger. When an ordinary variable that is not stored in a place the framework tracks changes, no view is recomputed; the body stays as it was. The tree below has three changes. filter is in the storage table. scrollOffset is read by a view but is not in the table: its source is unknown. visitCount is not in the table and is read by no view.

// main.swift — a key missing from the storage table produces no trigger
let storage: [String: Storage] = [
    "filter": .state(owner: "TaskScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("TaskScreen", parent: nil, reads: ["filter", "scrollOffset"]),
    ViewNode("FilterBar", parent: "TaskScreen", reads: ["filter"]),
], storage: storage)

let recomputes = [
    screen.change("filter-selected", key: "filter"),
    screen.change("scroll-changed", key: "scrollOffset"),
    screen.change("counter-increased", key: "visitCount"),
].flatMap { $0 }

countCauses(recomputes)
filter-selected: filter changed | recomputed TaskScreen local, FilterBar parent | skipped none
scroll-changed: scrollOffset changed | recomputed TaskScreen out-of-scope | skipped FilterBar
counter-increased: visitCount changed | recomputed none | skipped TaskScreen,FilterBar
local: 1
parent: 1
observed: 0
environment: 0

The scroll-changed line is a line the core cannot measure: TaskScreen is listed in it because it reads the value, but because the value is not in the table, the rule could not place it in one of the four categories and the line says out-of-scope. This line is not a claim of recomputation; in the framework a change to a value that is not stored produces no recomputation. out-of-scope is not a category; it means “where this value comes from is not in the model”, and countCauses does not count this line, so at the bottom only the recomputations of the filter-selected line appear: one local, one parent. In the counter-increased line no view reads the value; it reads recomputed none and both views are skipped. In the framework this corresponds to an ordinary untracked variable changing: the variable’s value changes, the screen does not.

The consequence for the lesson is a warning: a value updates the screen not because it was written to, but because it is stored in a place the framework tracks. Which storage places are tracked is the subject of the rest of the course.

VS3. This measurement covers only two keys missing from the table. How the framework shows, in the first drawing, a view that reads an untracked variable in its body, and how long it keeps the old value after a change, was not measured.

Summary

  • In a declarative interface the view derives from state; no code propagates the change, and a view does the reading.
  • A view that reads the changed value is recomputed, and one that does not is skipped.
  • The trigger is derived from where the value is stored: its own state is local, an ancestor’s state is parent, an object property is observed, an environment value is environment.
  • Six changes produced eight recomputations: local 2, parent 2, observed 2, environment 2.
  • A key missing from the storage table produces an out-of-scope line that the core cannot measure; this line is not a claim of recomputation and is not counted; a key nobody reads recomputes nothing.
  • This lesson’s trigger: the distribution of all four (2/2/2/2).

Next Step

In this lesson the tree and the reads were given; we did not look at how the view itself is built. In the next lesson, adding a modifier to a view changes what that view and the views beneath it read: a font set on a container spreads to the subtree and is read as environment. The question stays the same, but this time the trigger comes from the modifier’s propagation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close