---
title: 'UI Development with SwiftUI'
source: 'https://academia.sh/en/courses/swiftui'
language: en
updated: '2026-10-21T05:03:13+00:00'
license: 'CC BY-SA 4.0'
lessons: 21
outcomes:
    - 'Builds a declarative view tree with its sources of state'
    - 'Applies the layout system and its priority rules correctly'
    - 'Produces accessible and localized interfaces'
    - "Distinguishes whether a recomputation was triggered by the view's own state, by an ancestor, by an observed model or by the environment"
    - 'Builds the dependency tracking rule with a small model and recognizes that the model is not the framework itself'
---

# UI Development with SwiftUI

In a declarative interface a view is derived from state; when state changes, some views are recomputed and others are skipped. The course measures every mechanism with a single question — what triggered this recomputation? The view's own state, a value coming from an ancestor, an observed model, or an environment value spreading down the tree? Modifiers, stack and grid layouts, lists, bindings, observable objects, navigation and presentation, gestures, animations, accessibility and localization all pass through the same frame.

## The View System

Deriving a view from state, modifiers and the effect of their order, vertical, horizontal and depth stacks, the proposed size and the priority negotiation, grids and reading geometry, data-driven lists and forms, text, image and shape components, reusable view builders.

### Declarative Syntax

Source: https://academia.sh/en/courses/swiftui/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.

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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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`).

```swift
// 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.

```swift
// 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.

```swift
// 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.

### Views and Modifiers

Source: https://academia.sh/en/courses/swiftui/views-and-modifiers

A measurement showing that font and color modifiers placed on a container spread to the subtree as environment values, that modifier order changes the result, and that a view that sets its own font does not read the environment.

In the previous lesson six changes produced four triggers, and we saw that recomputations come out of the read sets of a tree. In this lesson we stop looking at the tree from outside for the first time and look at the tool that builds a view: the modifier. Adding `.font`, `.padding` or `.foregroundStyle` to a view does not change that view; every modifier added returns a new view value that wraps the view. Some modifiers affect only the look of that view, while others spread to the whole tree beneath it. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **environment**.

```swift
// 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 profile card: the screen that builds the card, the card itself, a title, a body text and a footer.

#### A Modifier That Spreads to the Subtree

A font and a foreground color are properties of a subtree, not of one view. When `.font(.title2)` is placed on a container, all the text inside uses that font; the text itself did not take this value as a parameter, it reads it from the environment. This is the environment value mechanism: the value flows down the tree from top to bottom, and every view that reads it uses it in its body. The snippet below shows this as source.

```swift
// Source snippet: the font and color placed on the container spread to the subtree
import SwiftUI

struct ProfileCard: View {
    let headline: String
    let isDimmed: Bool

    var body: some View {
        VStack(alignment: .leading) {
            Text(headline)
            Text("Software engineer")
            Text("Last seen: today")
        }
        .font(.title2)
        .foregroundStyle(.secondary)
        .opacity(isDimmed ? 0.5 : 1)
    }
}
```

None of the three texts was given a font; all three read the container's `.font` value. `.opacity`, on the other hand, does not spread to the subtree as an environment value; it is applied in the container's drawing and, in this snippet, comes from the card's parameter. The model separates these two paths: a value read from the environment is environment, and a value the parent supplies produces the parent trigger. In the tree, `font` and `foregroundStyle` are stored as environment values, and `headline` and `isDimmed` as the state of the screen that builds the card, `ProfileScreen`; the card takes these two values as `let` parameters and does not own them.

```swift
// main.swift — a change placed on the container spreads to the subtree as environment
let storage: [String: Storage] = [
    "font": .environment,
    "foregroundStyle": .environment,
    "headline": .state(owner: "ProfileScreen"),
    "isDimmed": .state(owner: "ProfileScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ProfileScreen", parent: nil, reads: ["headline", "isDimmed"]),
    ViewNode("ProfileCard", parent: "ProfileScreen", reads: ["headline", "isDimmed"]),
    ViewNode("CardTitle", parent: "ProfileCard", reads: ["headline", "font"]),
    ViewNode("CardBody", parent: "ProfileCard", reads: ["font", "foregroundStyle"]),
    ViewNode("CardFooter", parent: "ProfileCard", reads: ["font"]),
], storage: storage)

let recomputes = [
    screen.change("font-changed", key: "font"),
    screen.change("headline-changed", key: "headline"),
    screen.change("text-color-changed", key: "foregroundStyle"),
    screen.change("dimmed", key: "isDimmed"),
].flatMap { $0 }

countCauses(recomputes)
```

```
font-changed: font changed | recomputed CardTitle environment, CardBody environment, CardFooter environment | skipped ProfileScreen,ProfileCard
headline-changed: headline changed | recomputed ProfileScreen local, ProfileCard parent, CardTitle parent | skipped CardBody,CardFooter
text-color-changed: foregroundStyle changed | recomputed CardBody environment | skipped ProfileScreen,ProfileCard,CardTitle,CardFooter
dimmed: isDimmed changed | recomputed ProfileScreen local, ProfileCard parent | skipped CardTitle,CardBody,CardFooter
local: 2
parent: 3
observed: 0
environment: 4
```

Four changes produced nine recomputations: two local, three parent, four environment. In the `font-changed` line three views were recomputed with the environment trigger and the screen and the card were skipped; the card does not read `font`, it only delivers it to the views beneath it through the environment. In the `headline-changed` line the screen, which owns the value, is local; the card and the title are parent because they take the same value as a parameter; the body text and the footer do not read the headline, so they were skipped. In the `dimmed` line the screen was recomputed as local and the card with the parent trigger: below the screen only the card's body reads `isDimmed`, and the texts inside do not care about this value.

Reading the same key through two different paths is also possible, but this tree does not do it: `font` comes from the environment and `headline` comes as a parameter. The triggers of the two paths are separate. On the environment path, the one that supplies the value is a modifier above the view or the system; on the parent path, the one that supplies the value is the view directly above.

**VS4.** This measurement covers the keys `font`, `foregroundStyle`, `headline` and `isDimmed`. A change of the `font` key reads as a modifier above giving a new value or as a system setting; which one it is was not measured. Because the card takes the two values as `let` parameters, it does not own them; if the value were the card's own `@State` field, the card would be recomputed with the local trigger, and that case was not measured here. In both trees of this lesson, the screen added as the owner of the values is a model assumption; the source snippets show only the views' parameters.

#### Modifier Order

Because a modifier returns a wrapper, order carries meaning: the modifier that comes later wraps the view the earlier one returned. If `.padding()` comes first and `.background()` after, the background covers the view with the padding added; if the order is reversed, the background covers only the text and the padding stays outside. The two snippets below give the same two modifiers in two orders.

```swift
// Source snippet: padding first, background after
import SwiftUI

struct PaddedBadge: View {
    var body: some View {
        Text("New")
            .padding()
            .background(Color.yellow)
    }
}
```

```swift
// Source snippet: background first, padding after
import SwiftUI

struct TightBadge: View {
    var body: some View {
        Text("New")
            .background(Color.yellow)
            .padding()
    }
}
```

In the first, the yellow area also covers the space around the text; in the second, only the text itself. This difference is not a recomputation difference; the same keys are read in both views and the same changes recompute the same views. Order determines the look, not the trigger. That is why modifiers such as `padding` or `background` are not nodes in the tree; the nodes are the views that read values. The model builds this rule of the framework: a modifier that changes the look comes into play if the value it reads is a key in the tree; otherwise it does not appear in the model.

Both snippets are source only; the layout results belong to the platform's drawing engine and were not measured. How far the yellow area extends in each is a source reading in this lesson.

**VS5.** In this lesson, modifier order was read only in two snippets and only for `padding` and `background`. Order combinations of three or more modifiers, and the order of those that affect size such as `frame` and `offset`, were not measured.

#### The Bounding Measurement

An environment value reaches every view in the subtree, but only the view that reads it is recomputed when the value changes. When a child sets its own font, it no longer uses the container's `.font` value. The tree below builds this: `CardTitle` reads the font from the environment, while `CardNote` sets its own `.font` modifier and so no longer reads the `font` key, only the `note` value.

```swift
// main.swift — a view that sets its own font does not read the environment key
let storage: [String: Storage] = [
    "font": .environment,
    "headline": .state(owner: "ProfileScreen"),
    "note": .state(owner: "ProfileScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ProfileScreen", parent: nil, reads: ["headline", "note"]),
    ViewNode("ProfileCard", parent: "ProfileScreen", reads: ["headline", "note"]),
    ViewNode("CardTitle", parent: "ProfileCard", reads: ["headline", "font"]),
    ViewNode("CardNote", parent: "ProfileCard", reads: ["note"]),
], storage: storage)

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

countCauses(recomputes)
```

```
font-changed: font changed | recomputed CardTitle environment | skipped ProfileScreen,ProfileCard,CardNote
note-changed: note changed | recomputed ProfileScreen local, ProfileCard parent, CardNote parent | skipped CardTitle
local: 1
parent: 2
observed: 0
environment: 1
```

In the `font-changed` line `CardNote` is in the skipped list: the container's font changed, but because the note sets its own font, it does not read that value. In the `note-changed` line the screen, which owns the value, is local, and the card and the note are parent; `CardTitle` was skipped because it does not read the note. A modifier that spreads is therefore not a guarantee: a view that does not read the environment value is not recomputed when the environment value changes.

The counterpart in source is simple. The note overrides the font coming from the container with its own modifier; this is a view ceasing to consume an environment key.

```swift
// Source snippet: a view that sets its own font does not use the container's font
import SwiftUI

struct CardNote: View {
    let note: String

    var body: some View {
        Text(note)
            .font(.caption)
    }
}
```

This one is source only as well. We did not measure the framework's order of overriding a value from the container, that is, that the nearest modifier wins; we measured only the result of the read set in the model.

**VS6.** This measurement covers a single environment key and a single view. The case in which the override is partial, for example a view that sets its own font size but uses the container's weight, was not measured.

#### Summary

- A modifier does not change the view; it returns a new view, and the modifier that comes later wraps the earlier one.
- A font and a color placed on a container spread to the subtree as environment values; every view that reads them is recomputed with the environment trigger.
- A value the parent supplies as a parameter is not environment; it produces the parent trigger.
- Modifier order determines the look, not the trigger.
- A view that sets its own font does not read the environment key and is skipped when the environment changes.
- This lesson's trigger: **environment**.

#### Next Step

In this lesson the value flowed down the tree from top to bottom, and the reader took it and used it. In the next lesson the same flow appears as spacing and alignment in stacks that arrange views side by side or on top of each other: the value that determines a stack's spacing is the parent's state. The question stays the same, but this time the trigger comes from the own value of the container that arranges the children.

### Stack Layouts

Source: https://academia.sh/en/courses/swiftui/stack-layouts

A measurement showing that the spacing and axis a stack takes from its owner's state produce the parent trigger for its children, that layout direction comes from the environment, and that siblings in a depth stack cannot read each other's values.

In the previous lesson we measured that the effect of a modifier flows into the subtree as an environment value. In this lesson we look at the container in the same tree that arranges the children: the stack. A stack arranges its children horizontally, vertically or on top of each other, and it determines the space between them and their alignment with its own parameters. These parameters come from the state of the view that owns the stack. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **parent**.

```swift
// 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 toolbar: the screen that builds the bar, the stack that holds the bar and the icon, label and badge inside it.

#### Three Stacks, Three Axes

The horizontal stack arranges its children from left to right, the vertical stack from top to bottom, and the depth stack puts them on top of each other in the same area. In the first two, spacing determines the space between the children and alignment determines the children's position on the cross axis; the third has only alignment, because the children stand not side by side but on top of each other. The snippet below shows all three together.

```swift
// Source snippet: horizontal, vertical and depth stacks
import SwiftUI

struct ToolbarStack: View {
    let spacing: Double
    let isVertical: Bool

    var body: some View {
        VStack(spacing: spacing) {
            HStack(spacing: spacing) {
                Image(systemName: "pencil")
                Text("Edit")
            }
            ZStack(alignment: isVertical ? .topTrailing : .bottomTrailing) {
                Image(systemName: "bell")
                Text("3")
            }
        }
    }
}
```

`spacing` and `isVertical` are not stored values but parameters; the view that builds the stack received both from a view above. The badge reads the axis: its alignment is chosen by the `isVertical` value. The children inside the stack do not read the spacing themselves; the stack puts the spacing between the children. This lesson simplifies the model a little: every child that follows the stack's spacing counts as a reader that is recomputed when the spacing changes. What this does is make the parent trigger visible; it does not show how the framework actually passes the spacing to the children.

**VS7.** This measurement models the stack's spacing and axis values as values the child views read. Whether the framework recomputes the children when a stack changes, or only updates the layout, was not measured.

#### The Value That Descends From the Stack to the Child

A stack's spacing and axis come from the state of the view that builds the stack. If a child reads this value, the owner of the value is an ancestor of the child, and the model classifies the trigger as parent. In the tree below the owner of the two values is `ToolbarScreen`, which builds the stack; `ToolbarStack` takes both as `let` parameters. The icon and the label read the spacing, and the badge reads the axis (`isVertical`). The layout direction, on the other hand, is an environment value and is read by the label and the badge.

```swift
// main.swift — the stack's values are parent, the direction value produces the environment trigger
let storage: [String: Storage] = [
    "spacing": .state(owner: "ToolbarScreen"),
    "isVertical": .state(owner: "ToolbarScreen"),
    "layoutDirection": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ToolbarScreen", parent: nil, reads: ["spacing", "isVertical"]),
    ViewNode("ToolbarStack", parent: "ToolbarScreen", reads: ["spacing", "isVertical"]),
    ViewNode("ToolIcon", parent: "ToolbarStack", reads: ["spacing"]),
    ViewNode("ToolLabel", parent: "ToolbarStack", reads: ["spacing", "layoutDirection"]),
    ViewNode("ToolBadge", parent: "ToolbarStack", reads: ["isVertical", "layoutDirection"]),
], storage: storage)

let recomputes = [
    screen.change("spacing-changed", key: "spacing"),
    screen.change("axis-changed", key: "isVertical"),
    screen.change("direction-changed", key: "layoutDirection"),
].flatMap { $0 }

countCauses(recomputes)
```

```
spacing-changed: spacing changed | recomputed ToolbarScreen local, ToolbarStack parent, ToolIcon parent, ToolLabel parent | skipped ToolBadge
axis-changed: isVertical changed | recomputed ToolbarScreen local, ToolbarStack parent, ToolBadge parent | skipped ToolIcon,ToolLabel
direction-changed: layoutDirection changed | recomputed ToolLabel environment, ToolBadge environment | skipped ToolbarScreen,ToolbarStack,ToolIcon
local: 2
parent: 5
observed: 0
environment: 2
```

In the `spacing-changed` line the screen, which owns the value, is local; the stack, the icon and the label were recomputed with the parent trigger; the badge was skipped because it does not read the spacing. In the `axis-changed` line the screen is again local, and the stack and the badge were recomputed with the parent trigger; the icon and the label were skipped. In the `direction-changed` line the screen and the stack were skipped, and the label and the badge were recomputed with the environment trigger: the writing direction is not the state of the screen that builds the stack but a value that enters the tree from outside.

The counter summarizes the same story: two local, five parent, two environment. The five parent triggers show how many views a single value change by the owner of the values can affect. A view that changes the spacing also recomputes every child that reads the spacing.

**VS8.** This measurement covers `spacing`, `isVertical` and `layoutDirection`. The case in which a value passes both to the stack and to the child as a parameter, where the same key is read through two paths, was not measured. The screen added as the owner of the values is a model assumption; the source snippet shows only the stack's parameters.

#### Direction and Axis Selection

It is possible to choose a stack's axis at run time: a layout value that switches between a horizontal and a vertical layout changes the axis while the children stay the same. The snippet below shows this as source.

```swift
// Source snippet: a layout that selects the axis at run time
import SwiftUI

struct AdaptiveToolbar: View {
    let isVertical: Bool

    var body: some View {
        let layout = isVertical ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
        layout {
            Image(systemName: "pencil")
            Text("Edit")
        }
    }
}
```

`isVertical` is a `let` parameter of this view, and the owner of the value is the view that builds it; when the value changes, a new layout value is built and the children stay the same. In the model this is the same as the `isVertical` key changing: the owner of the value, `ToolbarScreen`, is recomputed with the local trigger, and the stack and the children that depend on the axis are recomputed with the parent trigger. The direction, on the other hand, comes from the environment and has nothing to do with the layout value; in a language written right to left, a horizontal stack arranges its children in reverse, but this is not a decision of the stack's owner.

**VS9.** In this lesson axis selection was handled only in the source snippet and through the counterpart of the `isVertical` key in the model. Whether changing the layout value preserves the identity of the children was not measured.

#### The Bounding Measurement

In a depth stack the children share the same area, but in source one cannot reach the other's `@State` value: `@State` is private to its view, and a sibling can neither read nor write it. This constraint comes not from the stack but from `@State` being private to its view; it is the same for siblings in a horizontal or vertical stack. The model imitates this source constraint with its own rule: if the owner of the value is not an ancestor of the reader, the read counts as `off-rule`. The tree below deliberately builds in the model this read that cannot be written in source: the photo layer owns its own `photoName` value, and the overlay layer is modelled as if it read this value.

```swift
// main.swift — a sibling in a depth stack cannot read the other's value
let storage: [String: Storage] = [
    "photoName": .state(owner: "PhotoLayer"),
    "caption": .state(owner: "PhotoOverlay"),
]

let screen = ViewTree(nodes: [
    ViewNode("PhotoStack", parent: nil, reads: []),
    ViewNode("PhotoLayer", parent: "PhotoStack", reads: ["photoName"]),
    ViewNode("PhotoOverlay", parent: "PhotoStack", reads: ["caption", "photoName"]),
], storage: storage)

let recomputes = [
    screen.change("photo-changed", key: "photoName"),
    screen.change("caption-changed", key: "caption"),
].flatMap { $0 }

countCauses(recomputes)
```

```
photo-changed: photoName changed | recomputed PhotoLayer local, PhotoOverlay off-rule | skipped PhotoStack
caption-changed: caption changed | recomputed PhotoOverlay local | skipped PhotoStack,PhotoLayer
local: 2
parent: 0
observed: 0
environment: 0
```

In the `photo-changed` line the photo layer is local and the overlay was marked `off-rule`. The model sees that the owner of the value is not an ancestor of the reader and counts this read as off-rule; this is not a measurement of framework behaviour but the model catching a read that should not be built. If the two layers need the same value, the value moves to the common ancestor, here `PhotoStack`; then both layers read it with the parent trigger. In the `caption-changed` line only the overlay was recomputed.

The `off-rule` trigger does not appear in the counter, because `countCauses` counts only the four triggers: two local, the others zero. This shows that the total in the counter is not always equal to the number of recomputations; triggers that are not counted must be read separately.

**VS10.** This measurement covers two sibling views and a single shared key. The framework's ways of passing values between siblings, for example preference keys, were not measured.

#### Summary

- A stack arranges its children horizontally, vertically or on top of each other; spacing and alignment are the stack's parameters.
- The spacing and axis coming from the state of the view that builds the stack produce the parent trigger for the stack and the children.
- The layout direction comes from the environment; because it is not the state of the stack's owner, it produces the environment trigger.
- Axis selection affects the stack and the children that depend on it with a single value of the stack's owner.
- Siblings cannot read each other's `@State` values, even in a depth stack where they share the same area; the constraint comes from `@State` being private to its view, and the model classifies this read as off-rule.
- This lesson's trigger: **parent**.

#### Next Step

In this lesson the stack's spacing and axis descended from the owning parent to the children. In the next lesson the same descent works for sizes: how a parent determines how much room it proposes to a child, and which value this proposal depends on. The question stays the same, but this time the value is not a spacing but a proposed size.

### The Layout System

Source: https://academia.sh/en/courses/swiftui/layout-system

A measurement showing that the parent value that determines the proposed size descends to the children as the parent trigger, that the text size preference is read from the environment, and that a size proposal is not a measured value.

In the previous lesson the stack's spacing and axis descended from the owning parent to the children. In this lesson we follow the same descent for sizes. In the layout system a parent view tells its child how much room it proposes; the child chooses its own size from this proposal; and the parent then places the child with the size it chose. The proposal itself is a parent decision, but the value that determines this decision often sits in the parent's state. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **parent**.

```swift
// 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 summary panel: the panel itself, a title, a chart and a note.

#### Proposal, Choice, Placement

Layout proceeds in three steps. The parent proposes a size to the child; the child interprets this proposal according to its own content and chooses a size; and the parent places the child in its own area with the chosen size. The child may accept the proposal, stay smaller, or ask for a size larger than the proposal. The snippet below shows the steps as source.

```swift
// Source snippet: proposal, choice and placement
import SwiftUI

struct SummaryPanel: View {
    @State private var isCompact = false

    var body: some View {
        VStack {
            Text("Summary")
                .layoutPriority(isCompact ? 0 : 1)
            Rectangle()
                .frame(maxWidth: .infinity, minHeight: isCompact ? 40 : 120)
            Text("Note")
                .lineLimit(2)
        }
    }
}
```

`isCompact` is the panel's own state and affects the room proposal in two places: the layout priority of the title and the minimum height of the chart. The line limit of the note is fixed. `layoutPriority` says which child gets its place first when room runs short; the child with the higher priority chooses its size first, and the remaining room is divided among the other children. This snippet is source only, and what numbers the sizes come out as was not measured in this lesson.

**VS11.** This measurement describes the three steps of layout but does not compute sizes. The number the proposed size turns into, the division of `layoutPriority` values, and the placement of the chosen sizes were not measured.

#### The Value the Proposal Rests On

If the value that determines the proposal is in the parent's state, the child reading it produces the parent trigger. In the tree below `isCompact` and `density` are the panel's values; the title and the chart read them. The text size preference (`dynamicTypeSize`), on the other hand, comes from the environment and affects the room the title and the note need.

```swift
// main.swift — the value that determines the proposed size is parent, the text size is environment
let storage: [String: Storage] = [
    "isCompact": .state(owner: "SummaryPanel"),
    "density": .state(owner: "SummaryPanel"),
    "dynamicTypeSize": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("SummaryPanel", parent: nil, reads: ["isCompact", "density"]),
    ViewNode("SummaryTitle", parent: "SummaryPanel", reads: ["isCompact", "dynamicTypeSize"]),
    ViewNode("SummaryChart", parent: "SummaryPanel", reads: ["isCompact", "density"]),
    ViewNode("SummaryNote", parent: "SummaryPanel", reads: ["dynamicTypeSize"]),
], storage: storage)

let recomputes = [
    screen.change("became-compact", key: "isCompact"),
    screen.change("density-changed", key: "density"),
    screen.change("text-size-changed", key: "dynamicTypeSize"),
].flatMap { $0 }

countCauses(recomputes)
```

```
became-compact: isCompact changed | recomputed SummaryPanel local, SummaryTitle parent, SummaryChart parent | skipped SummaryNote
density-changed: density changed | recomputed SummaryPanel local, SummaryChart parent | skipped SummaryTitle,SummaryNote
text-size-changed: dynamicTypeSize changed | recomputed SummaryTitle environment, SummaryNote environment | skipped SummaryPanel,SummaryChart
local: 2
parent: 3
observed: 0
environment: 2
```

In the `became-compact` line the panel is local, and the title and the chart were recomputed with the parent trigger; the note was skipped. In the `density-changed` line only the chart reads it, so the panel is local and the chart is parent. In the `text-size-changed` line the panel and the chart were skipped, and the title and the note were recomputed with the environment trigger. The user changes the text size preference from a system setting; no parent's state is the owner of this value.

That the note does not read `isCompact` is consistent with the source snippet: the line limit is a fixed number. The read set is derived from the reading in the source snippet; a read that is not in the source is an assumption and is stated in VS12; because the note depends only on the text size, it is skipped in the `became-compact` line.

**VS12.** This measurement covers `isCompact`, `density` and `dynamicTypeSize`. It was assumed that the `density` value, which does not appear in the source snippet, is the panel's state and that only the chart reads it; this is a tree definition, not a measurement rule.

#### The Bounding Measurement

A proposed size is not a state value. It is a negotiation between the parent and the child: the parent proposes, the child chooses, and the result appears in the placement. In the model this negotiation has no key. Even if the chart is written as if it reads a `proposedWidth` value, the model cannot find it in the storage table and counts the trigger as out of scope.

```swift
// main.swift — if the proposed width is not in the table, the trigger is out of scope
let storage: [String: Storage] = [
    "isCompact": .state(owner: "SummaryPanel"),
]

let screen = ViewTree(nodes: [
    ViewNode("SummaryPanel", parent: nil, reads: ["isCompact"]),
    ViewNode("SummaryChart", parent: "SummaryPanel", reads: ["isCompact", "proposedWidth"]),
    ViewNode("SummaryNote", parent: "SummaryPanel", reads: []),
], storage: storage)

let recomputes = [
    screen.change("width-proposed", key: "proposedWidth"),
    screen.change("became-compact", key: "isCompact"),
].flatMap { $0 }

countCauses(recomputes)
```

```
width-proposed: proposedWidth changed | recomputed SummaryChart out-of-scope | skipped SummaryPanel,SummaryNote
became-compact: isCompact changed | recomputed SummaryPanel local, SummaryChart parent | skipped SummaryNote
local: 1
parent: 1
observed: 0
environment: 0
```

The `width-proposed` line is a line the core cannot measure: the chart is listed because it reads the value, but the line says `out-of-scope` and is not a claim of recomputation; the counter does not show it either. The model makes no computation here: it does not know what the proposed width is as a number, what the chart chooses, or the placement in the panel. It only says which view reads which key and whether the key is in the table. The `became-compact` line shows how the result changes for a value that is in the table.

**VS13.** This measurement reduces the size negotiation to a single key and examines the case in which that key is not in the table. Which views a real size change recomputes, the framework's layout transitions, and the child's answer to the proposal were not measured.

#### Summary

- Layout has three steps: the parent proposes, the child chooses its size, and the parent places the child.
- If the value that determines the proposed size is in the parent's state, the child that reads it is recomputed with the parent trigger.
- The text size preference comes from the environment and produces the environment trigger.
- `layoutPriority` determines who chooses a size first when room runs short; its numeric division was not measured in this lesson.
- A proposed size is not a state value; if it has no key in the model, the trigger is classified as out of scope.
- This lesson's trigger: **parent**.

#### Next Step

In this lesson the parent value was the basis of the size proposed to the child. In the next lesson we ask the same question for a grid: what determines how many columns a grid shows. Part of the answer is a size class coming from the environment, so the trigger will be environment.

### Grids and Geometry

Source: https://academia.sh/en/courses/swiftui/grids-and-geometry

A measurement showing that the size class determines the grid columns as an environment value, that grid data descends from the parent, and that a measured size is left out of scope because it is not state.

In the previous lesson the value that determined the proposed size was the parent's state. In this lesson we look at how many columns a grid shows, and part of the answer comes from outside the tree: the horizontal size class of the screen. A grid builds its cell layout from column definitions; the number of columns usually depends on a value that the view building the grid reads from the environment. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **environment**.

```swift
// 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 gallery: the gallery screen, the grid, a cell and a caption.

#### From Column Definitions to the Grid

A lazy vertical grid builds its layout from a list of column definitions. Three flexible columns arrange the cells into three columns; when the definition list gets shorter, the same cells spread over fewer columns. The snippet below ties the number of columns to the horizontal size class.

```swift
// Source snippet: the size class determines the number of columns
import SwiftUI

struct GalleryGrid: View {
    @Environment(\.horizontalSizeClass) private var sizeClass
    let names: [String]

    var body: some View {
        let count = sizeClass == .compact ? 2 : 4
        let columns = Array(repeating: GridItem(.flexible()), count: count)
        LazyVGrid(columns: columns) {
            ForEach(names, id: \.self) { name in
                Text(name)
            }
        }
    }
}
```

`sizeClass` is read from the environment and determines the number of columns of the grid. When the screen rotates or the window size changes, the value changes and the grid's body is recomputed. `names` is a parameter that comes from the view building the grid.

**VS14.** This measurement treats the horizontal size class as a single environment key. At what threshold the size class changes and which window width the value corresponds to were not measured.

#### Size Class and Grid Data

In the tree below the gallery screen owns a filter tag; the grid and the cell read this tag. The grid also owns its own photo count and reads the size class from the environment. The caption reads the size class too, because on a small screen it has to fit on one line.

```swift
// main.swift — the size class is environment, the grid data is parent, the photo count is local
let storage: [String: Storage] = [
    "horizontalSizeClass": .environment,
    "filterTag": .state(owner: "GalleryScreen"),
    "photoCount": .state(owner: "GalleryGrid"),
]

let screen = ViewTree(nodes: [
    ViewNode("GalleryScreen", parent: nil, reads: ["filterTag"]),
    ViewNode("GalleryGrid", parent: "GalleryScreen", reads: ["horizontalSizeClass", "photoCount", "filterTag"]),
    ViewNode("GalleryCell", parent: "GalleryGrid", reads: ["filterTag"]),
    ViewNode("GalleryCaption", parent: "GalleryGrid", reads: ["horizontalSizeClass"]),
], storage: storage)

let recomputes = [
    screen.change("class-changed", key: "horizontalSizeClass"),
    screen.change("tag-changed", key: "filterTag"),
    screen.change("count-changed", key: "photoCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
class-changed: horizontalSizeClass changed | recomputed GalleryGrid environment, GalleryCaption environment | skipped GalleryScreen,GalleryCell
tag-changed: filterTag changed | recomputed GalleryScreen local, GalleryGrid parent, GalleryCell parent | skipped GalleryCaption
count-changed: photoCount changed | recomputed GalleryGrid local | skipped GalleryScreen,GalleryCell,GalleryCaption
local: 2
parent: 2
observed: 0
environment: 2
```

In the `class-changed` line the grid and the caption were recomputed with the environment trigger; the screen and the cell were skipped. The cell does not know the number of columns, it only draws its own content. In the `tag-changed` line the screen is local, and the grid and the cell were recomputed with the parent trigger. In the `count-changed` line only the grid was recomputed, with the local trigger: the owner of the photo count is the grid itself.

The three triggers came by three different paths: environment, parent and local. The grid is the view that reads the most, and for that reason it is the view recomputed the most; the grid was in the list on every change, while the screen and the cell appeared only when the filter changed.

**VS15.** This measurement covers `horizontalSizeClass`, `filterTag` and `photoCount`. Whether the cells are recomputed when the grid's number of columns changes, that is, how the framework preserves cell identity, was not measured.

#### The Geometry Reader

A geometry reader gives its content the size proposed to it and its coordinates. The content draws itself according to this size. The snippet below splits into two branches according to the width the reader provides.

```swift
// Source snippet: drawing according to the width the geometry reader provides
import SwiftUI

struct AdaptiveBanner: View {
    var body: some View {
        GeometryReader { proxy in
            if proxy.size.width > 400 {
                Text("Wide view")
            } else {
                Text("Narrow view")
            }
        }
    }
}
```

The `proxy.size` the reader provides is not a state value: nobody records it and nobody owns it. The value is computed in the layout pass from the size the parent proposed. That is why geometry has no storage place in the model.

#### The Bounding Measurement

In the tree below the reader is written as if it reads a `readerSize` value, but this key is not in the storage table. In the table there is only the screen's own mode.

```swift
// main.swift — if the geometry reader's size is not in the table, the trigger is out of scope
let storage: [String: Storage] = [
    "mode": .state(owner: "ReaderScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: ["mode"]),
    ViewNode("SizeReader", parent: "ReaderScreen", reads: ["mode", "readerSize"]),
    ViewNode("ReaderLabel", parent: "ReaderScreen", reads: ["mode"]),
], storage: storage)

let recomputes = [
    screen.change("size-measured", key: "readerSize"),
    screen.change("mode-changed", key: "mode"),
].flatMap { $0 }

countCauses(recomputes)
```

```
size-measured: readerSize changed | recomputed SizeReader out-of-scope | skipped ReaderScreen,ReaderLabel
mode-changed: mode changed | recomputed ReaderScreen local, SizeReader parent, ReaderLabel parent | skipped none
local: 1
parent: 2
observed: 0
environment: 0
```

The `size-measured` line is a line the core cannot measure: the reader is listed because it reads the value, but the line says `out-of-scope` and is not a claim of recomputation; the screen and the label appear as skipped. The model does no size computation here: it only sees that the key being read is not in the table and counts the trigger as out of scope. In the `mode-changed` line, for the value that is in the table, the screen is local, and the reader and the label were recomputed with the parent trigger.

**VS16.** This measurement reduces geometry to a single key that is not in the table. Which views a real geometry change recomputes, how many times the reader is called in a layout pass, and the behavior of coordinate spaces were not measured.

#### Summary

- A grid builds its cell layout from column definitions; the number of columns often depends on an environment value.
- The horizontal size class is read from the environment, and the grid and the caption that read it are recomputed with the environment trigger.
- The parent state that supplies the grid data produces the parent trigger for the cells.
- A value the grid owns itself produces the local trigger.
- The size a geometry reader provides is not state; if it has no key in the model, the trigger is classified as out of scope.
- This lesson's trigger: **environment**.

#### Next Step

In this lesson the grid's data and the size class came by two separate paths. In the next lesson we ask the same question for a list: what happens when the rows of a list are bound to a model's collection that lives outside its own tree. This time the trigger comes from a model outside the tree.

### Lists and Forms

Source: https://academia.sh/en/courses/swiftui/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.

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

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

### Visual Components

Source: https://academia.sh/en/courses/swiftui/visual-components

A measurement showing that the color scheme determines the icon color as an environment value, that the fill ratio of a shape descends from the parent, that the loading state of an image stays local, and that a static image reads no value.

In the previous lesson the trigger came from a model outside the tree. In this lesson we look at the leaves of the tree: text, icon, shape and image. These components do not contain other views, but they do read values: the color of an icon, the fill of a shape and the loading state of an image each depend on a value. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **environment**.

```swift
// 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 meter panel: the screen that builds the panel, the panel, a shape, an icon and an avatar.

#### Leaf Components

Text, icon, shape and image are the leaves of the tree. Each takes its form or content from a value. The snippet below shows all four together.

```swift
// Source snippet: text, icon, shape and image
import SwiftUI

struct MeterPanel: View {
    let fillRatio: Double

    var body: some View {
        VStack {
            Text("Fill level")
            Image(systemName: "gauge")
            Capsule()
                .fill(Color.blue)
                .frame(width: 200 * fillRatio, height: 12)
            Image("logo")
        }
    }
}
```

The width of the shape comes from the panel's `fillRatio` parameter. The icon, on the other hand, takes its foreground color from the environment. `Image("logo")` is a static image built with an asset name and reads no value.

**VS20.** This measurement handles leaf components through the values they read. The drawing cost of the components, the size of the icon set and the resolution of the asset catalog were not measured.

#### Color Scheme and Fill Ratio

In the tree below the color scheme comes from the environment and is read by the icon and the avatar. The fill ratio is the state of the screen that builds the panel, `MeterScreen`; the panel takes it as a `let` parameter and passes it to the shape. The loading state of the avatar belongs to the avatar itself.

```swift
// main.swift — the color scheme is environment, the fill ratio is parent, the loading state is local
let storage: [String: Storage] = [
    "colorScheme": .environment,
    "fillRatio": .state(owner: "MeterScreen"),
    "isLoading": .state(owner: "AvatarImage"),
]

let screen = ViewTree(nodes: [
    ViewNode("MeterScreen", parent: nil, reads: ["fillRatio"]),
    ViewNode("MeterPanel", parent: "MeterScreen", reads: ["fillRatio"]),
    ViewNode("MeterShape", parent: "MeterPanel", reads: ["fillRatio"]),
    ViewNode("MeterIcon", parent: "MeterPanel", reads: ["colorScheme"]),
    ViewNode("AvatarImage", parent: "MeterPanel", reads: ["isLoading", "colorScheme"]),
], storage: storage)

let recomputes = [
    screen.change("color-scheme-changed", key: "colorScheme"),
    screen.change("fill-changed", key: "fillRatio"),
    screen.change("loading-finished", key: "isLoading"),
].flatMap { $0 }

countCauses(recomputes)
```

```
color-scheme-changed: colorScheme changed | recomputed MeterIcon environment, AvatarImage environment | skipped MeterScreen,MeterPanel,MeterShape
fill-changed: fillRatio changed | recomputed MeterScreen local, MeterPanel parent, MeterShape parent | skipped MeterIcon,AvatarImage
loading-finished: isLoading changed | recomputed AvatarImage local | skipped MeterScreen,MeterPanel,MeterShape,MeterIcon
local: 2
parent: 2
observed: 0
environment: 2
```

In the `color-scheme-changed` line the icon and the avatar were recomputed with the environment trigger; the screen, the panel and the shape were skipped. In the `fill-changed` line the screen, which owns the value, is local, and the panel and the shape were recomputed with the parent trigger. In the `loading-finished` line only the avatar was recomputed, with the local trigger: the avatar keeps the loading state within itself.

Its counterpart in source is a view that loads a remote image. While loading continues a placeholder is shown, and when it finishes the image itself is shown; when the state changes only this view is recomputed.

```swift
// Source snippet: a placeholder is shown while a remote image loads
import SwiftUI

struct AvatarImage: View {
    let url: URL?

    var body: some View {
        AsyncImage(url: url) { image in
            image.resizable()
        } placeholder: {
            ProgressView()
        }
    }
}
```

**VS21.** This measurement covers `colorScheme`, `fillRatio` and `isLoading`. Whether the loading state really is the view's own state, that is, where the framework stores this state, was not measured; in the model the avatar was counted as its owner. The screen added as the owner of the fill ratio is also a model assumption; the source snippet shows only the panel's parameter.

#### The Bounding Measurement

A static image reads no value. That is why no change makes it recompute. In the tree below the logo image is a leaf with an empty read set; the panel reads its own mode, and the caption reads the panel's mode.

```swift
// main.swift — a static image reads no value
let storage: [String: Storage] = [
    "mode": .state(owner: "LogoPanel"),
]

let screen = ViewTree(nodes: [
    ViewNode("LogoPanel", parent: nil, reads: ["mode"]),
    ViewNode("StaticLogo", parent: "LogoPanel", reads: []),
    ViewNode("LogoCaption", parent: "LogoPanel", reads: ["mode"]),
], storage: storage)

let recomputes = [
    screen.change("mode-changed", key: "mode"),
    screen.change("asset-changed", key: "logoAsset"),
].flatMap { $0 }

countCauses(recomputes)
```

```
mode-changed: mode changed | recomputed LogoPanel local, LogoCaption parent | skipped StaticLogo
asset-changed: logoAsset changed | recomputed none | skipped LogoPanel,StaticLogo,LogoCaption
local: 1
parent: 1
observed: 0
environment: 0
```

In the `mode-changed` line the panel is local and the caption was recomputed with the parent trigger; the logo was skipped. In the `asset-changed` line no view was recomputed, because no view reads the key: all three views were skipped. A static image is in the tree but is not sensitive to any trigger; to be recomputed it has to read a value. The effect of its parent rebuilding it was not measured in VS22.

**VS22.** This measurement rests on the assumption that recomputing the parent does not rebuild a static child. Whether the framework reruns the bodies of the child views when the parent is recomputed was not measured.

#### Summary

- Text, icon, shape and image are the leaves of the tree; each takes its form from a value.
- The color scheme is read from the environment; the icon and the avatar that read it are recomputed with the environment trigger.
- The parent state that supplies the fill ratio of a shape produces the parent trigger for the shape.
- The loading state of a remote image belongs to the image itself and produces the local trigger.
- A static image reads no value; it is not recomputed on any change.
- This lesson's trigger: **environment**.

#### Next Step

In this lesson the leaves took their values from the environment, from the parent and from within themselves. In the next lesson we look at the tool that builds the tree: the view builder. Which value does content that depends on a condition, or a child view built with a closure, depend on? The trigger this time is parent.

### View Builders

Source: https://academia.sh/en/courses/swiftui/view-builders

A measurement showing that a condition inside builder content produces the parent trigger when it depends on the parent's state, that a builder component's own pressed state stays local, and that a closure is not a node in the tree.

In the previous lesson the leaves took their values from the environment, from the parent and from within themselves. In this lesson we look at the tool that builds the tree: the view builder. The expressions written one under another in a body, a condition or a loop are gathered by the builder into a single view. The content of a builder component can also be given as a closure. The question stays the same: **what triggered this recomputation?** The trigger examined in this lesson is **parent**.

```swift
// 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 disclosure panel: the screen that builds the panel, the panel, a detail block, a pressable button and its label.

#### From Expressions to a View

A view builder is a construct that gathers the expressions in a body into a single value. When two texts are written one under another, the two together become a single view; when a condition is written, only one of the true and false branches is shown. The snippet below ties the panel's detail block to a condition.

```swift
// Source snippet: the builder gathers the condition and the expressions into a single view
import SwiftUI

struct DisclosurePanel: View {
    let showDetails: Bool
    let itemCount: Int

    var body: some View {
        VStack {
            Text("Item count: \(itemCount)")
            if showDetails {
                Text("Details here")
            }
        }
    }
}
```

`showDetails` is a parameter that comes from the owner of the panel and determines whether the condition is true or false. While the condition is false the detail block is not in the tree; while it is true it is. In the model this is the detail block being a node that reads the `showDetails` value.

**VS23.** This measurement models the conditional block as a node that is always in the tree but reads the `showDetails` value. The block leaving the tree while the condition is false, that is, its identity being destroyed and added again, was not measured.

#### Condition and Builder Component

In the tree below the owner of the `showDetails` and `itemCount` values is `PanelScreen`, which builds the panel; the panel takes both as `let` parameters, and the detail block reads the first. The pressable button owns its own `isPressed` state, and its label gives emphasis by reading this value.

```swift
// main.swift — the condition is parent, the builder component's pressed state is local
let storage: [String: Storage] = [
    "showDetails": .state(owner: "PanelScreen"),
    "itemCount": .state(owner: "PanelScreen"),
    "isPressed": .state(owner: "PressableButton"),
]

let screen = ViewTree(nodes: [
    ViewNode("PanelScreen", parent: nil, reads: ["showDetails", "itemCount"]),
    ViewNode("DisclosurePanel", parent: "PanelScreen", reads: ["showDetails", "itemCount"]),
    ViewNode("DetailBlock", parent: "DisclosurePanel", reads: ["showDetails"]),
    ViewNode("PressableButton", parent: "DisclosurePanel", reads: ["isPressed"]),
    ViewNode("ButtonLabel", parent: "PressableButton", reads: ["isPressed"]),
], storage: storage)

let recomputes = [
    screen.change("detail-opened", key: "showDetails"),
    screen.change("count-changed", key: "itemCount"),
    screen.change("pressed", key: "isPressed"),
].flatMap { $0 }

countCauses(recomputes)
```

```
detail-opened: showDetails changed | recomputed PanelScreen local, DisclosurePanel parent, DetailBlock parent | skipped PressableButton,ButtonLabel
count-changed: itemCount changed | recomputed PanelScreen local, DisclosurePanel parent | skipped DetailBlock,PressableButton,ButtonLabel
pressed: isPressed changed | recomputed PressableButton local, ButtonLabel parent | skipped PanelScreen,DisclosurePanel,DetailBlock
local: 3
parent: 4
observed: 0
environment: 0
```

In the `detail-opened` line the screen, which owns the value, is local, and the panel and the detail block were recomputed with the parent trigger; the button and its label were skipped. In the `count-changed` line the screen is local and the panel was recomputed with the parent trigger; the detail block was skipped because it does not read the count. In the `pressed` line the button is local and the label was recomputed with the parent trigger.

If the condition inside the builder content depends on the parent's state, the block that uses the condition is recomputed with the parent trigger. The button's own pressed state, on the other hand, does not spill outside the button: the panel and the detail block are skipped in this change.

**VS24.** This measurement covers `showDetails`, `itemCount` and `isPressed`. Which gesture event changes the pressed state of the button, that is, the transition between touch and release, was not measured. The screen added as the owner of the values is a model assumption; the source snippet shows only the panel's parameters.

#### Builder Component

Taking the content of a component as a closure is the typical form of a builder component. The component calls the closure and places the resulting view inside its own layout. The snippet below defines a card component with a builder.

```swift
// Source snippet: a builder component that takes its content as a closure
import SwiftUI

struct InfoCard<Content: View>: View {
    @State private var isPressed = false
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        content
            .padding()
            .opacity(isPressed ? 0.6 : 1)
            .onTapGesture { isPressed.toggle() }
    }
}
```

The component owns its own pressed state and does not touch the content itself. The side that uses the card gives the content of the card as a closure; the closure is called and the result is stored as a view value.

#### The Bounding Measurement

The closure itself is not a view. It produces a value when called and ends where it was called. That is why it is not a node in the tree; the value read inside the closure belongs not to the view that calls the closure but to the view that builds the content. In the tree below the screen builds the content of the card; the card is only a shell.

```swift
// main.swift — the closure is not a node in the tree, the read belongs to the view that builds the content
let storage: [String: Storage] = [
    "title": .state(owner: "HostScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("HostScreen", parent: nil, reads: ["title"]),
    ViewNode("CardShell", parent: "HostScreen", reads: []),
    ViewNode("CardContent", parent: "CardShell", reads: ["title"]),
], storage: storage)

let recomputes = [
    screen.change("title-changed", key: "title"),
    screen.change("closure-changed", key: "contentClosure"),
].flatMap { $0 }

countCauses(recomputes)
```

```
title-changed: title changed | recomputed HostScreen local, CardContent parent | skipped CardShell
closure-changed: contentClosure changed | recomputed none | skipped HostScreen,CardShell,CardContent
local: 1
parent: 1
observed: 0
environment: 0
```

In the `title-changed` line the screen is local and the content was recomputed with the parent trigger; the card was skipped. The one that reads the title is the content, not the shell. In the `closure-changed` line no view was recomputed: the closure itself is neither in the storage table nor in the read sets, because it is not a node in the tree.

**VS25.** This measurement treats the closure as a construct that is not a node in the tree and does not model it even as a key. In the body of which view the closure is evaluated, and when the builder calls the content, were not measured.

#### Summary

- A view builder gathers the expressions, conditions and loops in a body into a single view.
- If the condition inside builder content depends on the parent's state, the block that uses the condition is recomputed with the parent trigger.
- A builder component's own pressed state produces the local trigger and does not spill outside the component.
- A content closure is not a view and is not a node in the tree.
- The value read inside a closure belongs to the view that builds the content; the shell does not read it.
- This lesson's trigger: **parent**.

#### Next Step

We have finished this topic: how views are built in the tree and how values flow through the tree. The next topic deals with a view's own state; its first lesson is Local State: what changes when a view stores its own counter, and who is recomputed. The trigger will be local.

## State and Navigation

Local state owned by a view, sharing state with a child view through a binding, observable objects outside the view, environment values passed down the tree, the programmatic navigation stack and path, sheet, alert and cover presentation, tabs and toolbars.

### Local State

Source: https://academia.sh/en/courses/swiftui/local-state

Measuring that a view's own state recomputes only that view, that lifting the same state to an ancestor turns the trigger into parent, and that a sibling's state leaves the read off-rule.

The previous topic completed how views are built: a view was a value described with modifiers and builders. This topic looks at the variables inside that description. The opening lesson showed that six changes produce eight recomputations and that two of them are local; both local rows came from a view's **own** value. The trigger examined in this lesson is **local**: the view that owns the value is the one that experiences the recomputation.

The measurement again uses the core file below. It does not change during the lesson, and every lesson in the course gives it verbatim.

```swift
// 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)")
    }
}
```

`Storage.state(owner:)` says which view a value belongs to. The `cause` function compares that owner with the view affected by the change: if the owner is the view itself, the trigger is `local`; if the owner is an ancestor of the view, it is `parent`; if it is neither, it is `off-rule`. The model builds this rule of the framework; the model is not the framework itself.

#### A View's Own State

A value that changes over time and concerns only one view is kept as **local state**. The value is marked with a property wrapper, `@State`. The view below keeps a tap counter. The counter is marked `private`: it is not exposed outside the view, and only the view's own body reads and writes it.

```swift
import SwiftUI

struct CounterCard: View {
    @State private var tapCount = 0

    var body: some View {
        VStack {
            Text("Taps: \(tapCount)")
            Button("Increment") { tapCount += 1 }
        }
    }
}
```

A value marked `@State` lives not inside the view value but in a store the framework manages. A view is a struct and can be rebuilt on every recomputation; the counter is not reset by each newly built value, because the store holds the value, not the view. The owner is the view that uses this store through its own identity. In the measurement this distinction becomes the `owner` field.

In the tree below, `DashboardView` is a root, `CounterCard` is its child, and `StatusLabel` is its second child. The type and property names in the source fragment above appear unchanged in the tree: the key is `tapCount`, the owner is `CounterCard`. Which value each view reads is recorded in `reads`, following the bodies in the source; `DashboardView` and `StatusLabel` do not read the counter.

```swift
// main.swift — a view's own state recomputes only that view
let storage: [String: Storage] = [
    "tapCount": .state(owner: "CounterCard"),
]

let screen = ViewTree(nodes: [
    ViewNode("DashboardView", parent: nil, reads: []),
    ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
    ViewNode("StatusLabel", parent: "DashboardView", reads: []),
], storage: storage)

let recomputes = [
    screen.change("tapped", key: "tapCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
tapped: tapCount changed | recomputed CounterCard local | skipped DashboardView,StatusLabel
local: 1
parent: 0
observed: 0
environment: 0
```

One change, one recomputation: only `CounterCard` was recomputed, and the trigger is `local`. `DashboardView` and `StatusLabel` were skipped because neither reads `tapCount`. This is the same as the `row-expanded` row in the opening lesson: the value belongs to the view, and the effect of the change stays in that view. The last four lines of the output show the count for the four categories; local is one, and the other three are zero.

This is the payoff of local state: the scope of a change is limited to the owner's body. A view living elsewhere on the screen does not know about the counter and does not have to.

**SN1.** This measurement covers a single change and a single key. That the counter value survives every time the view is rebuilt was explained in a sentence but not measured; the model's `ViewTree` type does not carry the lifetime of a value.

#### Lifting State to an Ancestor

If another view also needs to know the counter, local state is not enough. The state has to move to the common ancestor of the interested views; this is called **state lifting**. The ancestor becomes the owner of the state, hands the value to the child as a parameter, and passes the child a closure for changing it.

```swift
import SwiftUI

struct DashboardView: View {
    @State private var tapCount = 0

    var body: some View {
        VStack {
            CounterCard(tapCount: tapCount, onTap: { tapCount += 1 })
            StatusLabel()
        }
    }
}

struct CounterCard: View {
    let tapCount: Int
    let onTap: () -> Void

    var body: some View {
        VStack {
            Text("Taps: \(tapCount)")
            Button("Increment", action: onTap)
        }
    }
}
```

The view still draws the same screen; what changed is where the value lives. `CounterCard` no longer stores `tapCount`; it only shows the value it is given. The owner is now `DashboardView`, and because its body passes the value to the child, it reads it. Since ownership moved, the tree's storage table changes too: the same key, a new owner.

```swift
// main.swift — when state is lifted, the same key produces a parent trigger
let storage: [String: Storage] = [
    "tapCount": .state(owner: "DashboardView"),
]

let screen = ViewTree(nodes: [
    ViewNode("DashboardView", parent: nil, reads: ["tapCount"]),
    ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
    ViewNode("StatusLabel", parent: "DashboardView", reads: []),
], storage: storage)

let recomputes = [
    screen.change("tapped", key: "tapCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
tapped: tapCount changed | recomputed DashboardView local, CounterCard parent | skipped StatusLabel
local: 1
parent: 1
observed: 0
environment: 0
```

The same change now recomputed two views. `DashboardView` is local because it owns the value; `CounterCard` is parent because it is a child view that reads the value without owning it. `StatusLabel` is still skipped: an ancestor that takes ownership of a value does not recompute every child, only the ones that read it. That is the cost of state lifting: the same tap produces two recomputations instead of one. In return, other views on the screen can read the counter as well.

Comparing the numbers with the earlier measurement, the table reads as follows: local state is one recomputation, one local; state lifted to an ancestor is two recomputations, one local and one parent. What changed is not the value itself but its owner. The same `CounterCard` was recomputed with the `local` trigger in the first measurement and the `parent` trigger in the second; the trigger depends on the owner of the value, not on the type of the view.

**SN2.** This measurement covers two ownership arrangements: the counter was owned either by `CounterCard` or by `DashboardView`. Lifting ownership to an ancestor deeper than the root view, and several children reading the same value, were not measured.

#### Stay Local or Lift

The two measurements give a decision rule, and the rule looks at who reads the value. If only one view reads the value, the state stays in that view: a change produces a single recomputation and does not touch any other view's body. If several views read the value, the state moves up to the nearest common ancestor of those views, because the value must have a single source of truth. If two views each keep their own counter, the two counters change independently of each other and the screen shows the same thing in two different ways.

The second measurement has a side finding too: when an ancestor takes ownership of state, the children are recomputed, but only the children that read the value. A child that does not read it, like `StatusLabel` in the second measurement, is skipped. Keeping the state in the lowest possible common ancestor therefore meets two needs at once: it reaches the views that read the value and leaves the others unaffected.

#### The Bounding Measurement

State has an owner, and the value flows only from the owner to the views below it. Two sibling views cannot reach each other's state. In the tree below, the counter belongs to `CounterCard`, but its sibling `ResetButton` reads it too: there is no ancestor–descendant path, and the two merely share a common ancestor.

```swift
// main.swift — a view that reads a sibling's state stays off-rule
let storage: [String: Storage] = [
    "tapCount": .state(owner: "CounterCard"),
]

let screen = ViewTree(nodes: [
    ViewNode("DashboardView", parent: nil, reads: []),
    ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
    ViewNode("ResetButton", parent: "DashboardView", reads: ["tapCount"]),
], storage: storage)

let recomputes = [
    screen.change("tapped", key: "tapCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
tapped: tapCount changed | recomputed CounterCard local, ResetButton off-rule | skipped DashboardView
local: 1
parent: 0
observed: 0
environment: 0
```

`CounterCard` was recomputed as local. `ResetButton` received an `off-rule` row because it is neither the owner nor a descendant of the owner: the model cannot place this read in any of the four categories. It is not a category, and `countCauses` does not count this row; at the bottom only `CounterCard`'s local recomputation appears. The rule says this: state belongs to its owner and to the views below its owner. If a sibling needs to read it, the state is lifted to the common ancestor; the arrangement in the second section of this lesson is the way to do that.

**SN3.** This measurement covers only one sibling read and only the rule the model builds. How the framework handles code that tries to reach a sibling's state was not measured.

#### Summary

- A view's own state is local state; the owner of the value is that view.
- When local state changes, only the owner view is recomputed, and the trigger is local.
- Lifting state to an ancestor changes the owner: the ancestor becomes local, and the child that reads the value becomes parent.
- Depending on the owner of the value, the same view is recomputed with the local trigger in one measurement and the parent trigger in another.
- State flows only from its owner to the views below it; a sibling's read stays `off-rule` and is not counted.
- This lesson's trigger: **local**.

#### Next Step

When we lifted state to an ancestor, we gave the child a value and a closure; the child could change the value only through that closure. The next lesson does the same job with a single concept: the child is given not the value itself but a link that opens onto the value's owner. The question stays the same, but who triggers the recomputation changes when the child starts the change.

### Bindings

Source: https://academia.sh/en/courses/swiftui/bindings

Measuring that a child writing a parent's state through a binding is recomputed with the parent trigger, and that the trigger depends on the owner of the value, not on who started the change.

The previous lesson lifted state to an ancestor and gave the child two things: the value itself and a closure for changing it. The combination of these two parts is needed so often that the framework turns it into a single concept: the **binding**. A binding is not the value itself but a read-and-write path that opens onto the value's owner. The trigger examined in this lesson is **parent**: a child view reads and writes a value that is owned by someone else.

The measurement uses the same core file.

```swift
// 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 has no separate type for a binding. `Storage.state(owner:)` says who owns the value, and `reads` says which view reads it. A view that reads through a binding is still a view that reads; the model builds this rule of the framework's dependency tracking, and the model is not the framework itself.

#### A Link to the Owner

Suppose a screen holds a volume level. The level is the screen's state; the screen gives it to a step button, and the button must be able to raise the level. The child does not own the state; it receives a binding. The parent builds the binding by putting `$` in front of the value, and the child declares it with `@Binding`.

```swift
import SwiftUI

struct VolumeScreen: View {
    @State private var volume = 3

    var body: some View {
        VStack {
            Text("Volume: \(volume)")
            VolumeStepper(volume: $volume)
            FooterNote()
        }
    }
}

struct VolumeStepper: View {
    @Binding var volume: Int
    @State private var isPressed = false

    var body: some View {
        Button(isPressed ? "Pressed" : "Raise \(volume)") {
            volume += 1
            isPressed = true
        }
    }
}
```

`VolumeStepper` has two different values. `volume` is a binding: the value belongs to the screen, and the child only reads and writes it. `isPressed` is the child's own state: it concerns only the button, and nobody elsewhere on the screen knows about it. The distinction from the previous lesson sits side by side here; one value flows along the ancestor–descendant path, and the other stays with its owner.

In the tree, `VolumeScreen` and `VolumeStepper` share two values. `volume` is the screen's; the screen's body shows it, and so does the button's body. `FooterNote` reads neither. Two changes are applied: one to the binding's value, one to the child's transient state.

```swift
// main.swift — a binding produces a parent trigger, the child's own state a local one
let storage: [String: Storage] = [
    "volume": .state(owner: "VolumeScreen"),
    "isPressed": .state(owner: "VolumeStepper"),
]

let screen = ViewTree(nodes: [
    ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
    ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume", "isPressed"]),
    ViewNode("FooterNote", parent: "VolumeScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("level-raised", key: "volume"),
    screen.change("button-pressed", key: "isPressed"),
].flatMap { $0 }

countCauses(recomputes)
```

```
level-raised: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped FooterNote
button-pressed: isPressed changed | recomputed VolumeStepper local | skipped VolumeScreen,FooterNote
local: 2
parent: 1
observed: 0
environment: 0
```

In the `level-raised` row, two views were recomputed. `VolumeScreen` is the owner of the value, so it is local; `VolumeStepper` is the child that reads the value through a binding, so it is parent. The one who wrote the level was the child, but the child was not the owner. In the `button-pressed` row, only `VolumeStepper` was recomputed, and it is local: `isPressed` belongs to the child, is not tied to the screen through a binding, and the screen is not aware of this change. `FooterNote` was skipped in both changes.

The numbers come out like this: two changes produced three recomputations; two are local and one is parent. The same child view appeared in two rows with two different triggers: parent because one value arrived through a binding, local because another value is its own. The type of the view does not determine the trigger; the owner of each value that is read does.

**SN4.** This measurement covers two changes and two keys. The two assignments in the button's action were not treated as a single change; `volume` and `isPressed` were measured separately. Whether the framework merges the two changes in the same cycle was not measured.

#### A Binding and a Parameter Produce the Same Trigger

It is also possible to give the child the value as a plain parameter instead of a binding. A view that receives a parameter reads the value but cannot write it; to write, it needs a closure, which is the arrangement in the previous lesson. The question is whether the trigger differs between these two forms.

```swift
import SwiftUI

struct VolumeScreen: View {
    @State private var volume = 3

    var body: some View {
        VStack {
            VolumeStepper(volume: $volume)
            VolumeBadge(level: volume)
        }
    }
}

struct VolumeBadge: View {
    let level: Int

    var body: some View {
        Text("Level \(level)")
    }
}
```

`VolumeStepper` is the child that can write the value, and `VolumeBadge` only displays it. Both read the same value. The tree builds the two children together and applies a single change.

```swift
// main.swift — a child that reads gets the same trigger by binding or by parameter
let storage: [String: Storage] = [
    "volume": .state(owner: "VolumeScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
    ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume"]),
    ViewNode("VolumeBadge", parent: "VolumeScreen", reads: ["volume"]),
], storage: storage)

let recomputes = [
    screen.change("level-raised", key: "volume"),
].flatMap { $0 }

countCauses(recomputes)
```

```
level-raised: volume changed | recomputed VolumeScreen local, VolumeStepper parent, VolumeBadge parent | skipped none
local: 1
parent: 2
observed: 0
environment: 0
```

Three views were recomputed: the screen is local, and the two children are parent. There is no difference in the output between `VolumeStepper`, which reads through a binding, and `VolumeBadge`, which reads through a parameter. This is both a finding and a limit. The finding is that reading triggers the recomputation, not the authority to write. The limit is that the model does not separate write authority; `ViewNode` says only what is read. That a binding gives the child a path to write is a difference at the code level, not at the trigger level.

**SN5.** This measurement covers the case where both children read the value in the body. The case where a child only writes the binding and never reads it was not measured; in that case it would not appear in `reads`, so the model would classify it as skipped.

#### The Bounding Measurement

Who starts a change and who is triggered by it are separate questions. When a child writes through a binding, the first reading that comes to mind is that the trigger belongs to the child. The rule does not say that: **the rule does not read the writer**. The trigger looks at the owner of the value and at the reader; it does not look at who wrote the change. The measurement below shows this with two changes to the same key: one represents the child writing, the other the parent writing. In the model, the only difference between them is the label.

```swift
// main.swift — the rule does not read the writer; the trigger follows the owner of the value
let storage: [String: Storage] = [
    "volume": .state(owner: "VolumeScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
    ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume"]),
], storage: storage)

let recomputes = [
    screen.change("child-wrote", key: "volume"),
    screen.change("parent-wrote", key: "volume"),
].flatMap { $0 }

countCauses(recomputes)
```

```
child-wrote: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped none
parent-wrote: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped none
local: 2
parent: 2
observed: 0
environment: 0
```

The two rows are identical: the screen is local, the child is parent. That the writer was the child did not make the child local. The count came out as local 2, parent 2; the writing view did not change any number. This lesson's trigger is therefore not defined by the child starting the change, but by the child reading a value it does not own.

**SN6.** This measurement shows an equality at the model level: the same key, the same tree, the same result. Whether the framework orders or groups a write coming from the child differently from a write coming from the parent was not measured.

#### Summary

- A binding is not a copy of a value but a read-and-write path that opens onto its owner.
- A child that reads a parent's state through a binding is recomputed with the parent trigger when the value changes; the owner of the state is recomputed with the local trigger.
- The child's own transient state is independent of the binding and recomputes only the child; the trigger is local.
- A child that reads through a binding and a child that reads through a parameter get the same trigger; the model does not separate write authority.
- The rule does not read the writer: whether the same change comes from the child or the parent, the trigger is set by the owner of the value.
- This lesson's trigger: **parent**.

#### Next Step

So far every value has lived in a view and has been read either in that view or beneath it. In the next lesson, the value lives not in a view but in a separate object, and the views that read it can be anywhere in the tree. The question stays the same, but the trigger goes by a new name: observed.

### Observable Objects

Source: https://academia.sh/en/courses/swiftui/observable-objects

Measuring that reading a property of an observable object produces a recomputation on change, verifying with real observation tracking that only a read property notifies, and showing what object-level notification changes.

In the previous lesson a child view read a value through a binding, and the value still lived in a view. In this lesson the value lives not inside a view but in a separate object. A screen's data, the result of a network call or a disk read, a list shared by several screens: instead of being a field of a view, these sit inside an **observable object**. Views hold the object, read its properties, and are recomputed when a property they read changes. The trigger examined in this lesson is **observed**.

The measurement uses the core file, and for the first time in this topic it also measures the compiler's own observation tracking, outside the core.

```swift
// 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)")
    }
}
```

`Storage.model(object:)` says that a value is stored not in a view but in an object. The `cause` function does not enter the ownership question in this case: every view that reads an object property gets the observed trigger, wherever it stands in the tree. The model builds the framework's dependency-tracking rule; it is not the framework itself.

#### Property-Level Observation

The object below holds a library's list of books and its borrowed count. The `@Observable` macro makes every stored property of the class observable. The screen hands the object to its children; each child reads only the property it cares about.

```swift
import SwiftUI
import Observation

@Observable
final class LibraryStore {
    var books: [String] = []
    var borrowedCount = 0
}

struct LibraryScreen: View {
    let store: LibraryStore
    @State private var searchText = ""

    var body: some View {
        VStack {
            BookList(store: store, query: searchText)
            BorrowBadge(store: store)
        }
    }
}

struct BookList: View {
    let store: LibraryStore
    let query: String

    var body: some View {
        List(store.books.filter { $0.contains(query) }, id: \.self) { title in
            Text(title)
        }
    }
}

struct BorrowBadge: View {
    let store: LibraryStore

    var body: some View {
        Text("Borrowed: \(store.borrowedCount)")
    }
}
```

`BookList` reads only the `books` property, and `BorrowBadge` reads only the `borrowedCount` property. `searchText` is the screen's own state and is not in the object. The tree carries these reads exactly. In the storage table, the two object properties are tied to the `LibraryStore` object, and the search text to the `LibraryScreen` view. Three changes are applied: a book is added, a book is borrowed, a search is typed.

```swift
// main.swift — an object property is observed, the screen's own state produces a local trigger
let storage: [String: Storage] = [
    "books": .model(object: "LibraryStore"),
    "borrowedCount": .model(object: "LibraryStore"),
    "searchText": .state(owner: "LibraryScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("LibraryScreen", parent: nil, reads: ["searchText"]),
    ViewNode("BookList", parent: "LibraryScreen", reads: ["books", "searchText"]),
    ViewNode("BorrowBadge", parent: "LibraryScreen", reads: ["borrowedCount"]),
], storage: storage)

let recomputes = [
    screen.change("book-added", key: "books"),
    screen.change("book-borrowed", key: "borrowedCount"),
    screen.change("search-typed", key: "searchText"),
].flatMap { $0 }

countCauses(recomputes)
```

```
book-added: books changed | recomputed BookList observed | skipped LibraryScreen,BorrowBadge
book-borrowed: borrowedCount changed | recomputed BorrowBadge observed | skipped LibraryScreen,BookList
search-typed: searchText changed | recomputed LibraryScreen local, BookList parent | skipped BorrowBadge
local: 1
parent: 1
observed: 2
environment: 0
```

`book-added` recomputed only `BookList`, and `book-borrowed` recomputed only `BorrowBadge`; both are observed. Two views hold the same object, but each reads a different property of it, and a change to one does not affect the other. The `search-typed` row is not observed: the value lives on the screen, not in the object, so `LibraryScreen` was recomputed with the local trigger and `BookList` with the parent trigger. The counts: local one, parent one, observed two.

What sets the trigger here is not the object itself but the property that is read. The same `BookList` was recomputed with the observed trigger for `books` and with the parent trigger for `searchText`. This shows that the result of the second lesson holds for objects too: the trigger depends not on the type of the view but on where the value being read lives.

**SN7.** This measurement covers three changes and three keys. The case where an object property is read only indirectly in a view's body, for example through a computed property, was not measured; in the model, every view carries the key it reads directly in `reads`.

#### Real Tracking: Only the Read Property

The measurement above is a model; the tree builds the rule as we know it. The rule itself, that in an `@Observable` object only a change to a read property produces a notification, is the behavior of the compiler and the Observation module, and it can be measured directly. `withObservationTracking` tracks the properties read in its first closure and calls the second closure when one of them changes.

```swift
// main.swift — real observation tracking: only the read property produces a notification
import Observation

@Observable
final class LibraryStore {
    var books: [String] = []
    var borrowedCount = 0
}

let library = LibraryStore()
withObservationTracking {
    _ = library.borrowedCount
} onChange: {
    print("tracked property changed")
}
library.books.append("algorithms")
print("books changed")
library.borrowedCount += 1
print("borrowedCount changed 1")
library.borrowedCount += 1
print("borrowedCount changed 2")
```

```
books changed
tracked property changed
borrowedCount changed 1
borrowedCount changed 2
```

The tracking read only the `borrowedCount` property. When `books` changed, the callback did not run: there is no notification before the `books changed` line in the output. When `borrowedCount` changed, the callback ran, and the `tracked property changed` line was printed right after the assignment, before the `borrowedCount changed 1` line. So the notification arrived inside the assignment and synchronously. On the second increment, no notification came: tracking is **one-shot**. Once a change has been reported, tracking ends, and it must be set up again to see the next change. In views, setting it up again is the framework's job; this lesson does not measure it.

These two observations support the two rows in the tree. A change to a property that is not read recomputes nothing, and a change to a property that is read informs only the side that set up that read. In the tree, `BorrowBadge` being skipped when `books` changes is the model's reflection of this behavior.

**SN8.** This measurement concerns the `Observation` module itself; how the framework connects this tracking to a view body, and how many times the body is called, was not measured. Whether the callback runs before or after the assigned value is written was not measured either; the output shows only that it runs inside the assignment.

#### The Bounding Measurement

Not every observation mechanism works at the property level. An older path, `ObservableObject` and `@Published`, notifies at the object level: when any published property of the object changes, every view that watches the object is recomputed. This path depends on the Combine framework; it did not build in the measurement environment, so its source can only be shown.

```swift
import SwiftUI
import Combine

final class LibraryStore: ObservableObject {
    @Published var books: [String] = []
    @Published var borrowedCount = 0
}

struct BookList: View {
    @ObservedObject var store: LibraryStore

    var body: some View {
        List(store.books, id: \.self) { title in
            Text(title)
        }
    }
}

struct BorrowBadge: View {
    @ObservedObject var store: LibraryStore

    var body: some View {
        Text("Borrowed: \(store.borrowedCount)")
    }
}
```

The measurement below builds the model of this rule; it does not measure the framework itself. With object-level notification, the storage table has a single key, and the key is the name of the object. Every watching view reads this key, because the view cannot tell which property of the object changed.

```swift
// main.swift — with object-level notification the key is the name of the object
let storage: [String: Storage] = [
    "LibraryStore": .model(object: "LibraryStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("LibraryScreen", parent: nil, reads: []),
    ViewNode("BookList", parent: "LibraryScreen", reads: ["LibraryStore"]),
    ViewNode("BorrowBadge", parent: "LibraryScreen", reads: ["LibraryStore"]),
], storage: storage)

let recomputes = [
    screen.change("book-added", key: "LibraryStore"),
    screen.change("book-borrowed", key: "LibraryStore"),
].flatMap { $0 }

countCauses(recomputes)
```

```
book-added: LibraryStore changed | recomputed BookList observed, BorrowBadge observed | skipped LibraryScreen
book-borrowed: LibraryStore changed | recomputed BookList observed, BorrowBadge observed | skipped LibraryScreen
local: 0
parent: 0
observed: 4
environment: 0
```

Two changes recomputed two views twice each; the observed count is four. In the first, property-level measurement, the same two changes had produced two recomputations. The difference comes from the detail of the rule: because the key is the name of the object with object-level notification, the `book-added` change recomputed `BorrowBadge` as well. These rows are not a measurement of the framework; they are the model of the rule. The lesson builds this model to show what property-level observation gains: the view that does not read the changed property is skipped.

**SN9.** This measurement builds the rule of object-level notification as a model. How a real `ObservableObject` publishes, and whether a view is recomputed when a publication arrives with the same value, was not measured.

#### Summary

- A view that reads a property of an observable object is recomputed with the observed trigger when the property changes.
- Observation is at the property level: a change to a read property produces a notification, a change to an unread property does not.
- Real tracking is one-shot; to see a second change, tracking is set up again.
- The screen's own state is separate from the object's properties and produces a local or parent trigger.
- Object-level notification recomputes every view that watches the object on every change; property-level observation separates this.
- This lesson's trigger: **observed**.

#### Next Step

In this lesson we gave the object to the children as a parameter; the screen held the object and the child received it. Carrying the object to every view in the tree one by one forces the views in between to know the object only to pass it along. In the next lesson a value is placed once along the tree, and every view beneath it reads it directly. This time the trigger is environment.

### Environment Values

Source: https://academia.sh/en/courses/swiftui/environment-values

Measuring that an environment value recomputes only its reader along the tree without touching the views in between, that a property of an object placed in the environment triggers observation rather than environment, and how overriding in a subtree is built in the model.

In the previous lesson an object lived on its own, and to reach it we passed the object to every child as a parameter. The views in between had to carry it even when they had no interest in it. The **environment** removes this carrying: a value is placed at one point in the tree, and every view beneath it can read it directly. The trigger examined in this lesson is **environment**.

The measurement again uses the core file.

```swift
// 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)")
    }
}
```

`Storage.environment` says that a value belongs to no view and is a broadcast flowing down from that point in the tree. The `cause` function gives the `environment` trigger to every view that reads this key, without asking the ownership question. The model builds the framework's dependency-tracking rule; it is not the framework itself.

#### A Reader Deep in the Tree

The framework supplies some environment values itself: the color scheme, the text size setting, the locale. The application can add its own keys too. A custom key is defined with an extension on `EnvironmentValues`, and the `@Entry` macro builds the key together with its default value. A view reads the value with `@Environment`; the value is placed in an ancestor view with the `.environment` modifier.

```swift
import SwiftUI

extension EnvironmentValues {
    @Entry var textScale: Int = 1
}

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticleView()
            SidebarView()
        }
        .environment(\.textScale, 2)
    }
}

struct ArticleView: View {
    var body: some View {
        VStack {
            ParagraphView()
            CaptionView()
        }
    }
}

struct ParagraphView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Scale \(textScale)")
    }
}

struct CaptionView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Caption \(textScale)")
    }
}

struct SidebarView: View {
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Text(colorScheme == .dark ? "Dark" : "Light")
    }
}
```

`ArticleView` reads no environment value; it only hosts its two children. `ParagraphView` and `CaptionView` read the custom `textScale` key, and `SidebarView` reads the system's `colorScheme` value. In the tree these three reads are in `reads`. Two changes are applied: one to the value of the custom key, one to the system's color scheme. The first happens when the application changes the value in its `.environment` modifier, the second when the user changes the system setting; both are environment values.

```swift
// main.swift — an environment value recomputes only its reader, without touching what is in between
let storage: [String: Storage] = [
    "textScale": .environment,
    "colorScheme": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticleView", parent: "ReaderScreen", reads: []),
    ViewNode("ParagraphView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("CaptionView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("SidebarView", parent: "ReaderScreen", reads: ["colorScheme"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("mode-changed", key: "colorScheme"),
].flatMap { $0 }

countCauses(recomputes)
```

```
scale-changed: textScale changed | recomputed ParagraphView environment, CaptionView environment | skipped ReaderScreen,ArticleView,SidebarView
mode-changed: colorScheme changed | recomputed SidebarView environment | skipped ReaderScreen,ArticleView,ParagraphView,CaptionView
local: 0
parent: 0
observed: 0
environment: 3
```

In the `scale-changed` row, two views, `ParagraphView` and `CaptionView`, were recomputed; both are descendants, and the ancestor `ReaderScreen` is the view that places the value. `ArticleView` was in between and was skipped: because it does not read the environment value, the change did not touch it. In the `mode-changed` row only `SidebarView` was recomputed. All three recomputations are environment; in the count, environment is three and the rest are zero.

The gain of the environment is in that skipped row. If the value were carried two levels down the tree by parameter, `ArticleView` would have to take it as a parameter and pass it on, and it would itself count as a reader when the value changed. In the environment, the view in between does not know the value and is therefore not affected by the change.

**SN10.** This measurement covers two environment keys and two changes. The system's color scheme changes in reality with the user's setting; in the measurement the setting was represented as a key change, and how the framework delivers the change of the setting was not measured.

#### An Object Placed in the Environment

Not only simple values are placed in the environment; an observable object can be placed too. The object is provided with `.environment(session)` and read with `@Environment(SessionStore.self)`. In this case two separate things can change: the object itself, or a property of the object.

```swift
import SwiftUI
import Observation

@Observable
final class SessionStore {
    var unreadCount = 0
}

struct InboxBadge: View {
    @Environment(SessionStore.self) private var session

    var body: some View {
        Text("Unread: \(session.unreadCount)")
    }
}
```

`InboxBadge` takes the object from the environment, but what it reads in its body is the object's `unreadCount` property. What is tracked is therefore not the environment itself but an object property. In the tree these two distinctions are written as two different storages: `textScale` is environment, `unreadCount` is an object property.

```swift
// main.swift — a property of an object placed in the environment triggers observation, not environment
let storage: [String: Storage] = [
    "textScale": .environment,
    "unreadCount": .model(object: "SessionStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ParagraphView", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("InboxBadge", parent: "ReaderScreen", reads: ["unreadCount"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("unread-changed", key: "unreadCount"),
].flatMap { $0 }

countCauses(recomputes)
```

```
scale-changed: textScale changed | recomputed ParagraphView environment | skipped ReaderScreen,InboxBadge
unread-changed: unreadCount changed | recomputed InboxBadge observed | skipped ReaderScreen,ParagraphView
local: 0
parent: 0
observed: 1
environment: 1
```

Two changes produced two recomputations, and the triggers differ: `scale-changed` is environment, `unread-changed` is observed. `InboxBadge` reads an object it reaches through the environment, but what recomputes it is not the environment but the object property. An object taken from the environment is only an access path; the place where the value is stored determines the trigger. This matches the rule of the third lesson: however and by whomever an object property is read, the trigger is observation.

The counts: observed one, environment one.

**SN11.** This measurement is only for a change to a property of the object. The case where the object in the environment is replaced entirely by another object, that is, a change of the environment value itself, was not measured; the environment trigger would be expected in that case, but the model did not represent it separately as a key change.

#### The Bounding Measurement

An environment value can be **overridden** at a point in the tree: another value is placed for the same key in a subtree, and the views in that subtree read the overriding value rather than the upper one. A change to the upper value does not concern this subtree. The model, however, keeps a single storage row for each key; the same key cannot have two different values.

```swift
import SwiftUI

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticlePane()
            SidePane()
                .environment(\.textScale, 3)
        }
        .environment(\.textScale, 2)
    }
}
```

In the measurement below, the override is therefore represented with two separate keys: `textScale` carries the upper value, and `paneTextScale` carries the overriding value in the side pane. We write which view reads which by looking at the source; the model cannot find this out by itself.

```swift
// main.swift — an override in a subtree is a separate key in the model
let storage: [String: Storage] = [
    "textScale": .environment,
    "paneTextScale": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticlePane", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("SidePane", parent: "ReaderScreen", reads: ["paneTextScale"]),
], storage: storage)

let recomputes = [
    screen.change("upper-scale-changed", key: "textScale"),
    screen.change("side-scale-changed", key: "paneTextScale"),
].flatMap { $0 }

countCauses(recomputes)
```

```
upper-scale-changed: textScale changed | recomputed ArticlePane environment | skipped ReaderScreen,SidePane
side-scale-changed: paneTextScale changed | recomputed SidePane environment | skipped ReaderScreen,ArticlePane
local: 0
parent: 0
observed: 0
environment: 2
```

A change to the upper value recomputed only `ArticlePane`, and a change to the overriding value recomputed only `SidePane`. Both rows are environment. But it was the two keys we set up, not the measurement, that determined this result: if we had written a `textScale` read for `SidePane` in the tree, the model, not knowing overrides, would have recomputed it on the upper change as well. So the model can **represent** an override, but it cannot **distinguish** one. That an override shadows the upper value in the real framework is documented behavior; it is not something measured in this lesson.

**SN12.** This measurement is only for a representation with two keys. The case where the same key is read with two values in the upper and lower subtree, the case where overriding is nested across several layers, and the case where the overriding value is derived from the upper value were not measured.

#### Summary

- An environment value is placed at one point in the tree; a reader view beneath it reads it without touching the views in between.
- When the value changes, only the view that reads it is recomputed and the trigger is environment; an in-between view that does not read it is skipped.
- A system environment value and a custom `@Entry` key produce the same trigger.
- When a property of an object placed in the environment changes, the trigger is observation, not environment; the environment is only the access path.
- An override in a subtree is represented as a separate key in a single-key model; the model cannot distinguish an override.
- This lesson's trigger: **environment**.

#### Next Step

At this point all four triggers have been measured, and in every one of them what changed was a value. In the next lesson the screen itself is tied to a value: a path that says which screen will be shown. The navigation stack and its path are a state, and the question of which views a change to that state recomputes is again the same trigger question.

### The Navigation Stack and Path

Source: https://academia.sh/en/courses/swiftui/navigation-stack-and-path

Measuring that the navigation path is a state held at the root, that pushing and going back produce the same trigger, and that a value pushed onto the path is not a view.

The previous four lessons looked at where state lives: in a view, at the end of a binding, in an object, and in the environment. In this lesson what the state points to changes. Moving between screens is the change of a value that says which screen will be shown, and this value also sits in one of the storage places above. A **navigation stack** is the structure that holds the screens the user has opened on top of one another; the **path** is the sequence of values that describes the contents of this stack. The trigger examined in this lesson is **local**: the root view that holds the path is the owner of the value.

The measurement uses the core file.

```swift
// 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 has no type specific to navigation. The path is written as a view's state with `Storage.state(owner:)`; the views that read the path carry the `path` key in `reads`. The model builds the framework's dependency-tracking rule; it is not the framework itself.

#### The Path Sits at the Root

A navigation stack has two parts. The first is the `NavigationStack` at the root: we give it the path state through a binding. The second is the `.navigationDestination(for:destination:)` modifier, which says which view corresponds to each value on the path. In the source fragment below the path is an array of integers, and each integer is the identifier of an item.

```swift
import SwiftUI

struct RootScreen: View {
    @State private var path: [Int] = []
    @State private var store = ItemStore()

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                ItemList(path: $path)
                SettingsButton()
            }
            .navigationDestination(for: Int.self) { itemID in
                ItemDetail(itemID: itemID, store: store, path: $path)
            }
        }
    }
}

struct ItemList: View {
    @Binding var path: [Int]

    var body: some View {
        Button("Open, depth \(path.count)") {
            path.append(7)
        }
    }
}
```

The path belongs to `RootScreen`. The body gives the path to the `NavigationStack` through a binding and passes the same binding on to the child views. `ItemList` both reads and writes the path through this binding; it shows the depth in the button's text. `SettingsButton` does not touch the path. The tree carries the type names and the property name in the source fragment. Two changes are applied: a screen being pushed and going back.

```swift
// main.swift — the navigation path sits at the root, pushing and going back produce the same trigger
let storage: [String: Storage] = [
    "path": .state(owner: "RootScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("RootScreen", parent: nil, reads: ["path"]),
    ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
    ViewNode("SettingsButton", parent: "RootScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("screen-pushed", key: "path"),
    screen.change("went-back", key: "path"),
].flatMap { $0 }

countCauses(recomputes)
```

```
screen-pushed: path changed | recomputed RootScreen local, ItemList parent | skipped SettingsButton
went-back: path changed | recomputed RootScreen local, ItemList parent | skipped SettingsButton
local: 2
parent: 2
observed: 0
environment: 0
```

Two changes, four recomputations. The two rows are identical: `RootScreen` is local, `ItemList` is parent, `SettingsButton` was skipped. A user tapping a button starts the push, and the system's back gesture starts going back; the one who writes the change is different each time, but the owner of the path is the same, so the result is the same. This is an application of the rule of the second lesson: the rule does not read the writer. In the count, local is two, parent is two, observed and environment are zero.

Holding the path at the root is a design decision. If the path were held in a child view, the root and the siblings that do not read the path would have no access to it; this is the reason for lifting the state to an ancestor. The navigation path sits at the root from the start, because the decision of which screen to show depends on all the screens.

**SN13.** This measurement covers two changes and a single path key. The case where several values of the path change at once, for example emptying the whole stack, was not measured. How the `NavigationStack` finds the destination that corresponds to a value written to the path was not measured either.

#### The Object the Destination Shows

The path is a sequence of values: it is an integer, not a view. When a value is pushed onto the path, the framework calls the closure defined with `.navigationDestination` with that value and puts the resulting view on the stack. The destination view also reads an object's property when needed. In the source fragment below, let the destination be `ItemDetail`, which reads the item's title from an observable object; the path is also passed to it as a binding.

```swift
import SwiftUI
import Observation

@Observable
final class ItemStore {
    var itemTitle = "Draft"
}

struct ItemDetail: View {
    let itemID: Int
    let store: ItemStore
    @Binding var path: [Int]

    var body: some View {
        VStack {
            Text("\(itemID): \(store.itemTitle)")
            Button("Depth \(path.count)") { path.append(itemID + 1) }
        }
    }
}
```

`ItemDetail` reads two things: the length of the path and the object's title. This destination screen is now open and sits on the stack; the tree builds the trio of `RootScreen`, `ItemList` and `ItemDetail`. `ItemDetail` sits under the root, because the root builds the destination closure.

```swift
// main.swift — the object the destination shows is observed, the destination reading the path produces parent
let storage: [String: Storage] = [
    "path": .state(owner: "RootScreen"),
    "itemTitle": .model(object: "ItemStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("RootScreen", parent: nil, reads: ["path"]),
    ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
    ViewNode("ItemDetail", parent: "RootScreen", reads: ["path", "itemTitle"]),
], storage: storage)

let recomputes = [
    screen.change("title-changed", key: "itemTitle"),
    screen.change("second-screen-pushed", key: "path"),
].flatMap { $0 }

countCauses(recomputes)
```

```
title-changed: itemTitle changed | recomputed ItemDetail observed | skipped RootScreen,ItemList
second-screen-pushed: path changed | recomputed RootScreen local, ItemList parent, ItemDetail parent | skipped none
local: 1
parent: 2
observed: 1
environment: 0
```

`title-changed` recomputed only `ItemDetail`, and it is observed: the title is an object property and has nothing to do with the root that holds the path. `second-screen-pushed` recomputed three views at once: the root is local, `ItemList` and `ItemDetail` are parent. Because the destination screen is already on the stack, it reads the path and is recomputed when the path changes. The counts: local one, parent two, observed one.

Here two triggers meet in the same view. In the same measurement `ItemDetail` received the observed trigger once and the parent trigger once. Which change the destination view experiences depends on which value it reads and where the value is stored; navigation does not change that.

**SN14.** This measurement is for a destination that reads the path and a single object property. That a destination which never reads the path would not be recomputed when the path changes comes from the model's rule alone, unmeasured; whether the destination closure reads the path was not measured.

#### The Bounding Measurement

What is pushed onto the path is not a view but a value. Changing the path does not add a view to the tree; the framework builds the destination that corresponds to the value. For this reason, at the moment it first appears the destination is **a new node**, and that first appearance is not a recomputation. A recomputation is the body of a view that already existed being read again when a value changes. The measurement below applies the same change to two trees: one where the destination is not yet on the stack and one where it is.

```swift
// main.swift — a value pushed onto the path is not a view, the destination is a new node
let storage: [String: Storage] = [
    "path": .state(owner: "RootScreen"),
]

let beforePush = ViewTree(nodes: [
    ViewNode("RootScreen", parent: nil, reads: ["path"]),
    ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
], storage: storage)

let afterPush = ViewTree(nodes: [
    ViewNode("RootScreen", parent: nil, reads: ["path"]),
    ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
    ViewNode("ItemDetail", parent: "RootScreen", reads: ["path"]),
], storage: storage)

let recomputes = [
    beforePush.change("pushed-without-target", key: "path"),
    afterPush.change("pushed-with-target", key: "path"),
].flatMap { $0 }

countCauses(recomputes)
```

```
pushed-without-target: path changed | recomputed RootScreen local, ItemList parent | skipped none
pushed-with-target: path changed | recomputed RootScreen local, ItemList parent, ItemDetail parent | skipped none
local: 2
parent: 3
observed: 0
environment: 0
```

In the first row `ItemDetail` is neither among the recomputed nor among the skipped. There was no such node in the tree at that moment; the push will create it, but the model does not count the first body of a newly created node as a trigger. In the second row the destination was already on the stack and read the path, so it was recomputed with the parent trigger. In the count, local is two and parent is three; the push action in the first row did not count the destination's first appearance.

This distinction is a warning for the lesson: in navigation, "a screen opened" and "a screen was recomputed" are not the same event. A screen's first opening is a construction; a recomputation happens only after the screen exists, when a value it reads changes.

**SN15.** This measurement compares two cases, where the destination does not exist and where it does. How many steps the framework takes for the destination's first body computation, and whether the destination keeps its state when it is removed from the stack and built again, were not measured.

#### Summary

- The navigation path is a state held at the root; its owner is the root view.
- When the path changes, the root that holds it is recomputed with the local trigger and the child views that read the path with the parent trigger.
- Pushing a screen and going back produce the same recomputation set; who the writer is does not change the trigger.
- An object property shown by the destination view produces observed, and the path it reads produces the parent trigger.
- What is pushed onto the path is a value, not a view; the destination is a new node, and its first appearance does not count as a recomputation.
- This lesson's trigger: **local**.

#### Next Step

In this lesson the path added a screen to the stack, and the screen became part of the stack. Not every screen enters the stack: some open temporarily over the current screen and close when the work is done. In the next lesson a sheet, an alert and a full-screen cover are again tied to a value; presentation state and dismissal work like the path but with a different ownership.

### Presentation and Dismissal

Source: https://academia.sh/en/courses/swiftui/presentation-and-dismissal

Measuring that presentation state lives in the presenting view, that sheet content reads this state through a binding, and that the dismiss action is not a value.

A navigation stack adds a new screen to the stack and replaces the current screen; a presentation opens the new screen temporarily over the current one. A **sheet**, a **full-screen cover** or an **alert** does not exist on its own: a value says whether it is open, and this value, just like the navigation path, sits in a storage place. The trigger examined in this lesson is again **local**: the view that holds the presentation state is recomputed as the owner of the value. The lesson also looks at how dismissal works, because the dismiss action comes from another storage place, the environment.

The measurement uses the core file.

```swift
// 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 has no type specific to presentation. Presentation state is a `Bool` or an optional value, and in the model it is written as a `state` key; if the sheet's content reads this key, it carries it in its `reads` set.

#### Presentation State Lives in the Presenter

A sheet is opened with two parts: a `Bool` held in the presenting view and the `.sheet(isPresented:content:)` modifier. The modifier takes the binding, builds a view from the content closure when the value is true, and removes it when the value goes back to false. In the source fragment below the presenter is `InvoiceScreen`.

```swift
import SwiftUI

struct InvoiceScreen: View {
    @State private var isEditorPresented = false

    var body: some View {
        VStack {
            InvoiceHeader()
            Button("Edit") { isEditorPresented = true }
        }
        .sheet(isPresented: $isEditorPresented) {
            InvoiceEditor()
        }
    }
}

struct InvoiceHeader: View {
    var body: some View {
        Text("Invoice 2041")
    }
}
```

The state is in `InvoiceScreen`, and the modifier is written in the body of the same view. `InvoiceHeader` is not tied to the presentation at all. `InvoiceEditor` is built inside the closure. The presenter and the header are in the tree; while the sheet is open, the content enters the tree as well. First we measure the presentation itself, without the content: opening and closing concern the owner, not the writer.

```swift
// main.swift — presentation state lives in the presenter, opening and closing produce the same trigger
let storage: [String: Storage] = [
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceHeader", parent: "InvoiceScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("sheet-opened", key: "isEditorPresented"),
    screen.change("sheet-closed", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
```

```
sheet-opened: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceHeader
sheet-closed: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceHeader
local: 2
parent: 0
observed: 0
environment: 0
```

Two changes, two rows, and both recomputed only the presenter. `InvoiceHeader` was skipped both times: it does not read the presentation key. Presentation state is the view's own state and produces the local trigger. The count is local two, parent, observed and environment zero.

This is the plain form of the rule of the second lesson and of local state. An open sheet expands the tree, but the expansion does not count as a recomputation; when the sheet's content is built it computes its first body, and that is a construction.

**SN16.** This measurement covers two changes and a single `Bool` key. Presentation with an optional type (a sheet that uses `item:`) and the case where several presentations are used together in the same view were not measured. The time the sheet takes to appear on screen was not measured either.

#### Sheet Content Reads Through a Binding

It is common for sheet content to read the presentation state: a close button, a title that changes while the presentation is open. For this the content takes the state as a binding, so that it both reads and writes. This is the parent trigger of the second lesson: the owner of the state is the presenter, and the reader is the sheet's content.

```swift
import SwiftUI

struct InvoiceEditor: View {
    @Binding var isPresented: Bool

    var body: some View {
        VStack {
            Text(isPresented ? "Editing" : "Closing")
            Button("Done") { isPresented = false }
        }
    }
}
```

The presenting view now builds the content as `InvoiceEditor(isPresented: $isEditorPresented)`. In the measurement the tree has three views: the presenter, the header and the content. The same two changes are applied.

```swift
// main.swift — the sheet content reads the presentation key through a binding
let storage: [String: Storage] = [
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceHeader", parent: "InvoiceScreen", reads: []),
    ViewNode("InvoiceEditor", parent: "InvoiceScreen", reads: ["isEditorPresented"]),
], storage: storage)

let recomputes = [
    screen.change("sheet-opened", key: "isEditorPresented"),
    screen.change("sheet-closed", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
```

```
sheet-opened: isEditorPresented changed | recomputed InvoiceScreen local, InvoiceEditor parent | skipped InvoiceHeader
sheet-closed: isEditorPresented changed | recomputed InvoiceScreen local, InvoiceEditor parent | skipped InvoiceHeader
local: 2
parent: 2
observed: 0
environment: 0
```

In both changes the presenter is recomputed with the local trigger and the content with the parent trigger; the header is skipped. The count is local two, parent two. That the content stands in the tree as a child of the presenter is the condition of the parent trigger: the owner is an ancestor of the reader.

One detail needs attention here. The sheet's content is modeled as a child of the presenter, because the presenter builds the content closure. Even though the sheet appears in a separate layer on screen, in terms of ownership of the state it sits under the presenter in the tree.

**SN17.** This measurement is for a single tree in which the content is a direct child of the presenter. The case where the content passes the binding on to other views several levels down, and the case where the sheet carries a navigation stack of its own, were not measured.

#### The Bounding Measurement

There are two ways to close a sheet: the content writes `false` to the presentation binding, or it calls the **dismiss action** (`dismiss`) that comes from the environment. The second is read with `@Environment(\.dismiss)`. It is not a value but a callable action; calling it tells the framework "close the state that presents me", and the framework writes the presenter's state. The action by itself produces no change on its own. Reading the action from the environment does not mean waiting for an environment value to change, because the action itself is constant.

```swift
import SwiftUI

struct InvoiceEditor: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Close") { dismiss() }
    }
}
```

The measurement models what the dismiss call actually does. `dismiss` takes its place in the tree as an environment key that the content reads, and it is not changed, because the action itself is constant; what changes with the call is the presentation key.

```swift
// main.swift — dismiss is an action, not a value; closing changes the presenter's state
let storage: [String: Storage] = [
    "dismiss": .environment,
    "isEditorPresented": .state(owner: "InvoiceScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InvoiceScreen", parent: nil, reads: ["isEditorPresented"]),
    ViewNode("InvoiceEditor", parent: "InvoiceScreen", reads: ["dismiss"]),
], storage: storage)

let recomputes = [
    screen.change("dismiss-called", key: "isEditorPresented"),
].flatMap { $0 }

countCauses(recomputes)
```

```
dismiss-called: isEditorPresented changed | recomputed InvoiceScreen local | skipped InvoiceEditor
local: 1
parent: 0
observed: 0
environment: 0
```

The single row is the dismiss call changing the presentation key. This recomputes only the presenter with the local trigger; the content is skipped because it does not read this key. Because the `dismiss` key does not change, the environment counter is zero. The count is local one.

This row gives the result of the lesson. Reading the dismiss action does not tie the content to the presentation state; when the call is made, what changes is not the environment but the presenter's state. Because the action comes from the environment, it takes its place in the tree as an environment key, but the result of the call is seen in the presenter. If the content making the call does not read the presentation state, it does not take part in the recomputation of the closing either.

**SN18.** The measurement builds `dismiss` as an environment key that does not change; whether the action handle changes in the framework was not measured. The order in which the dismiss call writes the presentation state, and waiting for the closing animation to finish, were not measured.

#### Summary

- Presentation state is a value held in the presenting view; opening and closing produce the same trigger.
- The presenter is recomputed as the owner of the state with the local trigger, and the sheet content with the parent trigger if it reads through a binding.
- Sibling views unrelated to the presentation are skipped.
- `dismiss` is an action read from the environment; calling it changes the presenter's state, and the action itself is not a value.
- This lesson's trigger: **local**.

#### Next Step

In this lesson the presentation was managed with a single `Bool`, and its owner was the presenting view. In most applications, however, a screen is divided into several sections: a tab bar and a toolbar say which section will be shown and which actions will be offered. In the next lesson the selection sits at the root; the content of the sections reaches it through a binding, and whether it reads it is measured separately.

### Tabs and Toolbars

Source: https://academia.sh/en/courses/swiftui/tabs-and-toolbars

Measuring that the tab selection lives at the root, that content which only writes the selection through a binding does not count as a reader, and that a tab that is not selected is skipped because it does not read the selection.

The tab bar and the toolbar are two separate shells that stand below and above the screen. The first says which section is visible, the second which actions that section offers. Both rest on a value. In the tab bar this is the **selection**, a value held at the root; in the toolbar, whether a button is enabled usually comes from a property of an object. The trigger examined in this lesson is **local**: the selection sits at the root and the root reads it; the tab contents may take the selection through a binding, but they do not read it in their bodies.

The measurement uses the core file.

```swift
// 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 writes the selection as a `state` key and the object property that carries the button's enabled state as a `model` key.

#### The Selection Sits at the Root

A `TabView` takes the selection as a binding. Each tab's content carries a value with `.tag`; the framework matches the selection value against the tag and decides which content to show. In the source fragment below the root is the owner of the selection and passes it on to the contents as a binding.

```swift
import SwiftUI

struct ShellScreen: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            InboxTab(selectedTab: $selectedTab)
                .tag(0)
            ArchiveTab()
                .tag(1)
        }
    }
}

struct InboxTab: View {
    @Binding var selectedTab: Int

    var body: some View {
        Button("Go to archive") { selectedTab = 1 }
    }
}

struct ArchiveTab: View {
    var body: some View {
        Text("Archive")
    }
}
```

The selection is in `ShellScreen`. `InboxTab` takes the selection through a binding, because it must be able to switch to another tab with a button; but it does not read the value in its body, it only writes it in the button's action. The rule of the second lesson holds here too: a view that only writes does not enter the readers. `ArchiveTab` never takes the selection. First a selection change is measured with the root and two tabs: from the inbox to the archive, and from the archive back.

```swift
// main.swift — the tab selection sits at the root, a tab that does not read the selection is skipped
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("switched-to-archive", key: "selectedTab"),
    screen.change("back-to-inbox", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
```

```
switched-to-archive: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab
back-to-inbox: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab
local: 2
parent: 0
observed: 0
environment: 0
```

Two changes, two recomputations; in each row only the root is local, and both tabs are skipped. The count is local two.

Neither tab reads the selection value; they therefore do not need to know which tab is showing. The framework matches the selection against the tags, and the contents themselves do not take part in this matching. The content of a tab is skipped as long as it does not read the selection in its body. `InboxTab` carries the binding, but it was skipped because it only writes; content that read the selection in its body would get the parent trigger, and there is no such content in this tree.

**SN19.** This measurement covers two changes and two tabs. Whether the framework never builds the content of a tab that is not selected or keeps it in memory was not measured; the measurement only counts which view was recomputed.

#### The Toolbar Button Reads From an Object

Whether a button in the toolbar is enabled depends on the object the section shows: if there is a selected message in the inbox, the "Delete" button is enabled. The button is defined inside `.toolbar` and takes its enabled state from an object property. This is an object property; it concerns not the binding of the second lesson but the observed trigger of the third.

```swift
import SwiftUI
import Observation

@Observable
final class MailboxStore {
    var hasSelectedMessage = false
}

struct InboxToolbar: ToolbarContent {
    let store: MailboxStore

    var body: some ToolbarContent {
        ToolbarItem {
            Button("Delete") { }
                .disabled(!store.hasSelectedMessage)
        }
    }
}
```

In the measurement there are the root, the inbox tab, the toolbar button and the archive tab. Only the toolbar button reads the object property; the selection is read only at the root.

```swift
// main.swift — the enabled state of the toolbar button depends on an object property
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
    "hasSelectedMessage": .model(object: "MailboxStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("DeleteButton", parent: "InboxTab", reads: ["hasSelectedMessage"]),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("message-selected", key: "hasSelectedMessage"),
    screen.change("switched-to-archive", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
```

```
message-selected: hasSelectedMessage changed | recomputed DeleteButton observed | skipped ShellScreen,InboxTab,ArchiveTab
switched-to-archive: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,DeleteButton,ArchiveTab
local: 1
parent: 0
observed: 1
environment: 0
```

`message-selected` recomputed only the toolbar button, and it is observed: the root, the inbox and the archive were skipped. `switched-to-archive`, on the other hand, recomputed only the root as local; the inbox, the button and the archive do not read the selection and were skipped. The count is local one, observed one.

The sets of the two changes are disjoint: one touches the button, the other only the root. This is the measurable result of where the state lives: because the selection is at the root, the root is recomputed too; because the enabled state is in an object, the root is never touched.

**SN20.** This measurement covers two keys and a single toolbar button. The toolbar layout changing when the tab changes, and the case where the button is shared across several tabs, were not measured.

#### The Bounding Measurement

When the selected tab changes, the content of the previous tab leaves the screen, but this does not mean it received a recomputation. For a view to be recomputed, a value it reads must change. If a tab that is not selected does not read the selection, it is skipped every time the selection changes. The measurement shows this with three tabs: the first takes the selection through a binding but only writes it, and the other two never take the selection.

```swift
// main.swift — the content of a tab that is not selected is skipped unless it reads the selection
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
    ViewNode("SettingsTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("switched-to-settings", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
```

```
switched-to-settings: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab,SettingsTab
local: 1
parent: 0
observed: 0
environment: 0
```

All three tabs were skipped regardless of both the previous and the next selection; the inbox tab, which carries the binding, is among them. Only the root was recomputed. The count is local one.

The design consequence follows from here: in this model what decides a recomputation is not that a binding is given but that the value is read in the body. Giving a binding to content that only writes the selection does not move the computation out of the root; the moment the content starts reading the selection in its body, it gets the parent trigger. Whether the binding itself has a cost in the framework was not measured.

**SN21.** This measurement is for a single selection change and three tabs. What the framework does when tabs that are not selected leave the screen, for example preserving the view's state, was not measured.

#### Summary

- The tab selection is a state held at the root; when the selection changes, the root gets the local trigger.
- Tab content that takes the selection through a binding and only writes it does not count as a reader and is skipped; content that read the selection in its body would get the parent trigger.
- If the enabled state of a toolbar button comes from an object property, it produces the observed trigger and does not touch the root.
- A tab that is not selected is skipped on every selection change as long as it does not read the selection.
- This lesson's trigger: **local**.

#### Next Step

This topic measured where state lives, whom it recomputes, and how it is carried between screens. The next topic looks at the finger itself: during a drag the offset value changes on every frame, and that change is also a link in the same chain. The first lesson, Gestures, measures which view a drag offset recomputes.

## Interaction and Presentation Quality

Tap, drag and combined gestures, implicit and explicit animations, transitions for inserting and removing views, accessibility with labels, traits and Dynamic Type, localization with text, date and number formatting, a fast feedback loop with previews.

### Gestures

Source: https://academia.sh/en/courses/swiftui/gestures

Measuring that the temporary value during a drag recomputes only the view that carries the gesture, while the final position written when the gesture ends reaches its readers through the parent trigger.

The previous topic looked at the path by which a value is bound to a view; users usually change these values with a gesture. The question in this lesson does not change: **what triggered this recomputation?** This lesson's trigger is **local**. In the course's opening lesson, six changes produced eight recomputations; two belonged to the local trigger, two to parent, two to observed and two to environment. Now we will count which of these four categories a single drag uses.

A gesture is a declarative definition that turns the user's touch on the screen into a value: tap, long press, drag and their combinations are attached to a view as a modifier in the same way. One property of the drag determines the measurement. While the finger is on the screen, the position changes many times per second, but once the finger lifts, none of these intermediate values has to be permanent. For this the framework provides a separate property wrapper: `@GestureState`. The value is updated while the gesture runs, and when the gesture ends the value returns to its initial value by itself. The permanent result is written, in the gesture's end callback, to an ordinary state value.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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 Two Values of a Drag

The fragment below drags a card. The view that draws the card keeps the temporary offset in its own gesture state. The permanent position lives in a parent view and is passed to the card through a binding; a label also reads the position.

```swift
import SwiftUI

struct BoardScreen: View {
    @State private var position = CGSize.zero

    var body: some View {
        VStack {
            DragCard(position: $position)
            PositionLabel(position: position)
            HintText()
        }
    }
}

struct DragCard: View {
    @Binding var position: CGSize
    @GestureState private var dragOffset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .offset(
                x: position.width + dragOffset.width,
                y: position.height + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
                    .onEnded { value in
                        position.width += value.translation.width
                        position.height += value.translation.height
                    }
            )
    }
}

struct PositionLabel: View {
    let position: CGSize

    var body: some View {
        Text("\(Int(position.width)), \(Int(position.height))")
    }
}

struct HintText: View {
    var body: some View {
        Text("Drag the card")
    }
}
```

This fragment does not compile and produces no output; because it imports the platform framework, it stands as source only. We will read the tree from it and rebuild it with plain values. `DragCard` reads two values: `dragOffset` is its own gesture state, and `position` is the binding that comes from the parent. `PositionLabel` reads only `position`. `HintText` reads neither. `BoardScreen` owns the position and passes it in its body both as a binding and as a value; that is, its body reads the position too.

Before measuring, we state what the model is. The model builds the framework's dependency-tracking rule; it is not the framework itself: a view is recomputed when a value it reads changes, and skipped when a value it does not read changes. How many times the framework calls the body, or when it draws, is not measured here.

The core changes nothing; given a key, it finds the views that read it and classifies the trigger for each. Now we build the tree that carries the same names as the types in the source fragment above, and write three changes: `dragOffset` while the drag runs, `position` when the gesture ends, and the gesture state's return to its initial value.

```swift
// main.swift — three changes during and after the drag
let storage: [String: Storage] = [
    "position": .state(owner: "BoardScreen"),
    "dragOffset": .state(owner: "DragCard"),
]

let screen = ViewTree(nodes: [
    ViewNode("BoardScreen", parent: nil, reads: ["position"]),
    ViewNode("DragCard", parent: "BoardScreen", reads: ["position", "dragOffset"]),
    ViewNode("PositionLabel", parent: "BoardScreen", reads: ["position"]),
    ViewNode("HintText", parent: "BoardScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("drag-in-progress", key: "dragOffset"),
    screen.change("drag-ended-position", key: "position"),
    screen.change("drag-ended-offset", key: "dragOffset"),
].flatMap { $0 }

countCauses(recomputes)
```

```
drag-in-progress: dragOffset changed | recomputed DragCard local | skipped BoardScreen,PositionLabel,HintText
drag-ended-position: position changed | recomputed BoardScreen local, DragCard parent, PositionLabel parent | skipped HintText
drag-ended-offset: dragOffset changed | recomputed DragCard local | skipped BoardScreen,PositionLabel,HintText
local: 3
parent: 2
observed: 0
environment: 0
```

While the drag runs, a single view is recomputed: the card itself, because `dragOffset` is its gesture state and only its body reads it. Its two siblings and its parent are skipped. This is why dragging is cheap: each time the finger moves, the body of the label or of the hint text is not recomputed.

**IP1.** This measurement holds only for a tree in which the owner of `dragOffset` is `DragCard`. A layout that moved the gesture state up to a parent view would give different counts; that layout was not measured.

**IP2.** This measurement represents a single change during the drag. How many intermediate values the finger produces in a second, and whether the framework coalesces them, was not measured.

#### The Permanent Position Is Written to the Parent

The second row tells a different story. When the gesture ends, the permanent position is written to `position`, and the owner of this value is `BoardScreen`. The owner is itself recomputed, so the trigger is local. `DragCard`, which reads the value through a binding, and `PositionLabel`, which takes the value as a parameter, sit below the owner, so for them the trigger is **parent**: they did not make the change themselves, a value of a view above them changed. `HintText` is skipped again, because it reads nothing.

The third row is the gesture state's return to its initial value. The framework does this for us, but the model does not: the return was added by the author as a separate change in the model. The card is recomputed once more in this change, and the trigger is again local. This is why the final count gives three recomputations for the local trigger and two for the parent trigger: of the three local ones, two belong to the card and one to the screen; both parent ones come from the position change. The observed and environment counts are zero, because a drag never uses these two channels. The difference between this distribution and the 2/2/2/2 of the opening lesson comes from the kind of change: a single interaction usually stays within one or two channels.

The practical consequence of this distinction is as follows. If the value that moves with the finger is held in gesture state, siblings that do not read it are unaffected for the whole drag. If the same value were updated directly on `position`, every intermediate value would also recompute the label. Separating the two values is a design that fits the tool the framework provides, and the measurement shows this with numbers.

**IP3.** This measurement holds for a tree in which `DragCard`, `PositionLabel` and `HintText` are direct children of `BoardScreen`. A layout in which the readers sit deeper in the tree was not measured.

**IP4.** This measurement assumes that the value of `position` is written once when the gesture ends. At what moment, and on which thread, the end callback runs was not measured.

#### Taps and Combined Gestures

The drag is only one member of this family. Tap, long press and drag are separate gesture definitions; they can be attached to a view one by one or combined. Combination has three forms: one defines gestures that run at the same time, one defines gestures that run in sequence, and the other defines gestures where only one succeeds, with priority given to the first. The fragment below sets up a sequence that asks for a long press first and then a drag, and also binds a tap to a state value.

```swift
import SwiftUI

struct CardRow: View {
    @State private var isPinned = false

    var body: some View {
        Text("Card")
            .onTapGesture {
                isPinned.toggle()
            }
            .gesture(
                LongPressGesture()
                    .sequenced(before: DragGesture())
            )
    }
}
```

This fragment has no new trigger category. The tap closure writes to an ordinary state value; because the owner of the value is `CardRow`, the change is local, and if no other view reads `isPinned`, only the row itself is recomputed. The gesture itself is not a trigger: the trigger is the value written at the end of the gesture or updated during it. This distinction is the main idea of the lesson, and it explains why the measurement was made with the keys `dragOffset` and `position`. The model has no key called "drag"; the keys are the storage places that the gesture touches.

In a sequenced combination, the second gesture does not start until the first succeeds. This behavior belongs to the framework's gesture recognition rules and is not measured in this lesson; the model asks only which value is written at the end of the combination.

#### The Bounding Measurement

We show in a separate block what the model cannot measure. Not every piece of information a gesture offers is bound to a storage place: a value such as the drag velocity is read only inside the callback and is never held in any state. The model has no storage record for such a value. If we define a reading view and leave out the record, the core cannot classify the trigger.

```swift
// main.swift — a key with no storage record
let storage: [String: Storage] = [
    "position": .state(owner: "BoardScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("BoardScreen", parent: nil, reads: ["position"]),
    ViewNode("DragCard", parent: "BoardScreen", reads: ["position", "velocity"]),
], storage: storage)

let recomputes = screen.change("velocity-read", key: "velocity")
print(recomputes.count)
```

```
velocity-read: velocity changed | recomputed DragCard out-of-scope | skipped BoardScreen
1
```

The core writes `out-of-scope`, and this is not a trigger category; it is the model's way of saying "I do not know where you read this value from." The lesson's counts hold only for changes that fall into one of the four categories. When the gesture state returns to its initial value is in the same class: the model counts the return because the author added it, and does not claim the framework's timing. Whether the return happens before or after the end callback was not measured.

#### Summary

- A gesture is a declarative definition that turns the user's touch on the screen into a value; the temporary value is held in gesture state, the permanent result in state.
- Gesture state that changes during a drag recomputes only the view that reads it; the siblings are skipped.
- The position written when the gesture ends reaches its owner through the local trigger, and its children that read it through a binding or a parameter through the parent trigger.
- The gesture state's return to its initial value is a separate local change in the model; the framework's timing is not measured.
- This lesson's trigger: **local**.

#### Next Step

At the end of a drag the position changes and the view moves to its new place; whether this move is abrupt or smooth is a separate question. In the next lesson we will measure whether a change wrapped in an animation differs, from the trigger's point of view, from the same change left unwrapped.

### Implicit and Explicit Animations

Source: https://academia.sh/en/courses/swiftui/implicit-and-explicit-animations

Measuring that a change wrapped in an animation recomputes the same views through the same trigger as the identical change left unwrapped.

In the previous lesson we measured that when a drag ends the position changes and the card stays in its new place. Whether that change reaches the view abruptly or through intermediate values is a separate decision, and the tool that makes it is animation. The question is the same: **what triggered this recomputation?** The trigger examined in this lesson is **local**; a parent example sits beside it.

An animation is a declarative definition that spreads the reflection of a change onto the view over time. It is added in two ways. An explicit animation wraps the code that makes the change: whoever writes `withAnimation { isExpanded.toggle() }` has said "let this change be reflected with an animation." An implicit animation is attached to the view: the `.animation(_:value:)` modifier applies an animation to the changes beneath that view when the given value changes. The first is written where the change is made, the second in the view that sees the change. Neither changes a value; each only selects how the change is reflected.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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)")
    }
}
```

#### One Change, Two Wrappings

The fragment below has a panel. The panel's detail area opens and closes with its own state, `isExpanded`, and the button wraps this change in an explicit animation. The panel's opacity depends on the `isOn` value given by the screen, and an implicit animation follows this value.

```swift
import SwiftUI

struct PanelScreen: View {
    @State private var isOn = false

    var body: some View {
        VStack {
            Toggle("Enabled", isOn: $isOn)
            DetailPanel(isOn: isOn)
            StatusText()
        }
    }
}

struct DetailPanel: View {
    let isOn: Bool
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Button("Details") {
                withAnimation {
                    isExpanded.toggle()
                }
            }
            if isExpanded {
                Text("Detail text")
            }
        }
        .opacity(isOn ? 1 : 0.4)
        .animation(.default, value: isOn)
    }
}

struct StatusText: View {
    var body: some View {
        Text("Ready")
    }
}
```

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. `PanelScreen` owns `isOn` and uses it in its body both as a binding and as a value; that is, it reads the value. `DetailPanel` takes `isOn` as a parameter and reads it for its opacity, and reads `isExpanded` as its own state. `StatusText` reads neither. We rebuild this reading layout with plain values. The model builds the framework's dependency-tracking rule; it is not the framework itself.

We write two changes: the panel opening with an explicit animation, and the `isOn` value on the screen changing. In the second, the implicit animation is in effect.

```swift
// main.swift — a local change wrapped in an explicit animation and a parent value followed by an implicit one
let storage: [String: Storage] = [
    "isOn": .state(owner: "PanelScreen"),
    "isExpanded": .state(owner: "DetailPanel"),
]

let screen = ViewTree(nodes: [
    ViewNode("PanelScreen", parent: nil, reads: ["isOn"]),
    ViewNode("DetailPanel", parent: "PanelScreen", reads: ["isOn", "isExpanded"]),
    ViewNode("StatusText", parent: "PanelScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("panel-opened", key: "isExpanded"),
    screen.change("enabled-changed", key: "isOn"),
].flatMap { $0 }

countCauses(recomputes)
```

```
panel-opened: isExpanded changed | recomputed DetailPanel local | skipped PanelScreen,StatusText
enabled-changed: isOn changed | recomputed PanelScreen local, DetailPanel parent | skipped StatusText
local: 2
parent: 1
observed: 0
environment: 0
```

The first line is the explicit animation's measurement. The change inside `withAnimation` recomputes `DetailPanel`, the owner of `isExpanded`; the trigger is local. The screen and the status text are skipped. The animation did not change this count, because the wrapper does not determine which view reads; it only says how the reflection will happen.

The second line is the implicit animation's measurement. When `isOn` changes, its owner `PanelScreen` is recomputed through the local trigger, and `DetailPanel` through the parent trigger, because it takes the value from its parent. The view that carries the implicit animation is not the view that makes the change: `DetailPanel` carries the modifier, while the value is on the screen. `StatusText` is skipped again. The final count gives two local and one parent recomputation; observed and environment are zero.

**IP5.** This measurement holds only for a tree in which `isExpanded` is `DetailPanel`'s own state and `isOn` is the screen's state. A layout in which these two values belong to the same owner was not measured.

**IP6.** This measurement assumes that the body of `DetailPanel` reads `isOn`. A way of writing in which the value the modifier follows is not read in the body was not measured.

#### An Animation Is Not a Trigger

These two lines show the distinction at the center of the lesson. An explicit animation wraps a change, an implicit animation follows a value; neither produces a trigger. The trigger is still the value's own storage path: local state, a value from the parent, an observed object or an environment value. The animation adds no fifth channel to any of these four.

The practical consequence of this distinction shows up when debugging. If a view is seen being recomputed unexpectedly, one looks at which value changed before blaming the animation. Removing the animation does not remove the recomputation; it removes only the intermediate values. On the other hand, it matters which value an implicit animation is bound to: the `value:` parameter selects which change will be reflected with an animation. The form without a value parameter applies the animation to every change beneath the view and has been deprecated; for this reason the animation is written bound to a value with the `value:` parameter.

The advantage of the explicit animation is its scope. Changes made inside the `withAnimation` block are reflected with that animation; a change made outside the block does not receive this animation and, if there is no other animation source, is reflected without one. If the same value is written from two different places, one may be animated and the other not. The model does not separate these two writes.

In a code review this distinction turns into three questions. The first is whose the value is: is the view that holds the value the same as the view that changes it? If so, the trigger is local, and the animation, explicit or implicit, concerns only that view. The second is who reads the value: views that receive it from the parent as a parameter or a binding are recomputed through the parent trigger, and the view that carries the implicit animation is often one of them. The third is where the animation is written: in the code that makes the change, or in the view that follows the value? This third question does not change the measurement result, but it makes it easier for the reader to find the intent. A `withAnimation` block says the change is animated on purpose; an `.animation(_:value:)` modifier says the view should always react to this value with an animation.

In the example, `StatusText` was skipped on both lines. This does not mean that every view of an animated screen moves; only the view that reads the value is recomputed. A view that does not read stays as it is, unaffected by its sibling's animation.

**IP7.** This measurement shows, for only two changes, that an animation does not produce a trigger. Whether the framework does extra computation when the animation ends was not measured.

**IP8.** This measurement says nothing about duration, curve and frame rate. These three values were neither printed nor compared.

#### The Bounding Measurement

We write the same key twice; in one the change is wrapped in an animation, in the other it is not. The model carries no field that could set up this difference. This is a deliberate limit: the core's `change` function takes only the key and the label.

```swift
// main.swift — the same change with and without animation
let storage: [String: Storage] = [
    "isExpanded": .state(owner: "DetailPanel"),
]

let screen = ViewTree(nodes: [
    ViewNode("DetailPanel", parent: nil, reads: ["isExpanded"]),
    ViewNode("StatusText", parent: nil, reads: []),
], storage: storage)

let animated = screen.change("animated", key: "isExpanded")
let plain = screen.change("unanimated", key: "isExpanded")
print(animated.map(\.view) == plain.map(\.view))
print(animated.map(\.cause) == plain.map(\.cause))
```

```
animated: isExpanded changed | recomputed DetailPanel local | skipped StatusText
unanimated: isExpanded changed | recomputed DetailPanel local | skipped StatusText
true
true
```

The two changes give the same line, and both comparisons are true: the same view, the same trigger. The model cannot count animation; it does not need to, because this course's measure is the reason for a recomputation, not the view's intermediate values. How the animation looks on screen, how long it lasts, and whether the body is called again during the intermediate frames were not measured.

#### Summary

- An explicit animation wraps the change, an implicit animation wraps the view; neither changes the value, each selects the form of the reflection.
- A local change wrapped in an explicit animation recomputes the value's owner through the local trigger.
- If the value an implicit animation follows comes from the parent, that view is recomputed through the parent trigger.
- The same change, animated and unanimated, counts the same views through the same trigger in the model.
- This lesson's trigger: **local**.

#### Next Step

In this lesson the change always recomputed a view; the view stayed in the tree. In the next lesson we will measure a view being added to the tree and removed from it, that is, the transition. There we will look at which view an item deleted from a model recomputes, and why the view that goes away is not counted.

### Transitions

Source: https://academia.sh/en/courses/swiftui/transitions

Measuring that an item deleted from the model recomputes the list view through the observed trigger, that the state opening a detail area recomputes the screen through the local trigger, and that the departing view is not computed.

In the previous lesson we measured that an animation wraps the change, does not alter the trigger, and leaves the view in the tree. In this lesson the view does not stay in the tree: it either enters it or leaves it. The question is again the same: **what triggered this recomputation?** The trigger examined in this lesson is **observed**; a local example sits beside it.

A transition is a declarative definition that selects how a view is reflected as it is added to the tree or removed from it. A view takes part in the body depending on a condition; when the condition changes, the view arrives or goes. The `.transition(_:)` modifier selects a form for this entry and exit: fade, slide, scale or a combination of them. `AnyTransition.asymmetric(insertion:removal:)` gives the entry and the exit separate forms. For a transition to be seen, the insertion or removal change must be wrapped in an animation; without one, the view arrives or goes without a transition. For this reason transitions continue the second lesson: the animation wraps the change, and the transition shapes the effect of that change on the tree.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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 Detail Area and the List

The fragment below sets up an inbox. The messages live in an observable object, and the list reads this object. The screen shows a detail area depending on the `isShowingDetail` state, and the button wraps this change in an animation.

```swift
import SwiftUI

@Observable
final class InboxStore {
    var messages: [String] = []
    var unreadCount = 0
}

struct InboxScreen: View {
    let store: InboxStore
    @State private var isShowingDetail = false

    var body: some View {
        VStack {
            MessageList(store: store)
            Button("Details") {
                withAnimation {
                    isShowingDetail.toggle()
                }
            }
            if isShowingDetail {
                DetailBanner()
                    .transition(.opacity)
            }
        }
    }
}

struct MessageList: View {
    let store: InboxStore

    var body: some View {
        List(store.messages, id: \.self) { message in
            Text(message)
        }
    }
}

struct DetailBanner: View {
    var body: some View {
        Text("Details")
    }
}
```

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. `MessageList` reads `store.messages`; because `InboxStore` is an observable object, this read is through the observation channel. `InboxScreen` reads `isShowingDetail` in the condition in its body and owns the value. `DetailBanner` reads no value. The model builds the framework's dependency-tracking rule; it is not the framework itself.

Now we write two changes: a message being deleted from the model, and the detail area opening. The names in the tree are the same as the type names in the source fragment, and the keys are the property names.

```swift
// main.swift — a deleted item and an opening detail area
let storage: [String: Storage] = [
    "messages": .model(object: "InboxStore"),
    "isShowingDetail": .state(owner: "InboxScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
    ViewNode("MessageList", parent: "InboxScreen", reads: ["messages"]),
    ViewNode("DetailBanner", parent: "InboxScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("message-deleted", key: "messages"),
    screen.change("detail-opened", key: "isShowingDetail"),
].flatMap { $0 }

countCauses(recomputes)
```

```
message-deleted: messages changed | recomputed MessageList observed | skipped InboxScreen,DetailBanner
detail-opened: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList,DetailBanner
local: 1
parent: 0
observed: 1
environment: 0
```

The deleted message recomputes only `MessageList`, and the trigger is observed: neither the list itself nor its parent made the change; the object changed, and the list was reading the relevant property of that object. The screen is skipped, because it does not read `messages`. The change that opens the detail area recomputes the screen through the local trigger; the view that reads the condition and owns it is the screen. The list and the detail area appearing as `skipped` in the line says these two views were not computed in that change.

One point deserves attention: the entry of the detail area. The area entered the tree, but the model did not count this as a recomputation: `DetailBanner` is among the skipped. The first computation of a view is not the recomputation of an existing view, and the model measures only the second.

**IP9.** This measurement holds only for a tree with a single list that reads `messages`. A layout with several views reading the same object was not measured.

**IP10.** This measurement represents the deletion change with a single label. Which row of the list will go away, how the row identities are matched, and whether the transition is applied to the row were not measured.

#### Observation Tracking Reacts Only to the Property That Is Read

The rule of the observation channel is not "the object changed" but "the property that is read changed". This rule is not an assumption of the model; the observation tracking module is compiled in the sandbox and the rule is measured directly. In the block below, `InboxStore` carries two properties; the tracking reads only `messages`. First the untracked `unreadCount` changes, then the tracked `messages`.

```swift
// main.swift — observation tracking sees only the property that is read
import Observation

@Observable
final class InboxStore {
    var messages: [String] = ["report", "invoice"]
    var unreadCount = 0
}

let store = InboxStore()
withObservationTracking {
    _ = store.messages
} onChange: {
    print("tracked property changed")
}
store.unreadCount += 1
print("unreadCount changed")
store.messages.removeFirst()
print("messages changed")
```

```
unreadCount changed
tracked property changed
messages changed
```

The untracked `unreadCount` did not trigger the callback: no callback message came after the first line. The tracked `messages` did. The callback ran inside the assignment, synchronously: its message was printed before the `messages changed` line that comes after the assignment. Whether the callback ran before or after the value was written was not measured in this block; the lesson does not claim it. This measurement also does not show how the framework attaches this tracking to the view body. We can say that the framework computes the body inside a tracking; the details of that attachment are a matter for reading the source.

These two measurements need to be read together. The `observed` trigger in the model rests on the behavior in the tracking measurement: because the list reads `messages`, it is recomputed when that property changes, and not recomputed when `unreadCount` changes. If in an interface the change of an unread property does not affect the view, the reason is that the view never uses that property in its body; holding the object as a whole is not enough. The approach that notifies at the object level does not make this distinction: with `ObservableObject` and `@Published`, every published change invalidates all the views observing the object. That approach depends on the reactive-streams framework and is not measured here; it is mentioned as source.

**IP11.** This measurement measures the tracking itself, not the view tree. That the tracking ends after a single change and has to be set up again for a second change was not shown in this block.

**IP12.** This measurement covers a single object with two properties. The numbers of the approach that notifies at the object level were not measured.

#### The Bounding Measurement

What happens to the departing view? When the detail area closes, `DetailBanner` leaves the tree; it goes through no recomputation, reads no new value, and is not computed. The model shows this as follows: the tree knows a view only as far as it contains it. We write the same change in two trees, one where the area is in the tree and one where it is not.

```swift
// main.swift — the departing view is not in the tree
let storage: [String: Storage] = [
    "isShowingDetail": .state(owner: "InboxScreen"),
]

let before = ViewTree(nodes: [
    ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
    ViewNode("MessageList", parent: "InboxScreen", reads: []),
    ViewNode("DetailBanner", parent: "InboxScreen", reads: []),
], storage: storage)

let after = ViewTree(nodes: [
    ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
    ViewNode("MessageList", parent: "InboxScreen", reads: []),
], storage: storage)

let closing = before.change("detail-closing", key: "isShowingDetail")
let closed = after.change("detail-closed", key: "isShowingDetail")
print(closing.count == closed.count)
```

```
detail-closing: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList,DetailBanner
detail-closed: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList
true
```

The area that is closing appears in the `skipped` list in the first tree; in the second tree it does not appear at all. The view recomputed in both trees is the same: only `InboxScreen`. The departing view does not appear among the recomputed ones in any line. For this reason the model does not count the removal of a view as a separate event. When the exit transition ends, whether the departing view is computed during the exit, and when it is fully deleted from the tree were not measured.

#### Summary

- A transition shapes a view's entry into and exit from the tree; for it to be seen, the change must be wrapped in an animation.
- An item deleted from the model recomputes the list that reads that model through the observed trigger.
- Observation tracking reacts only to the property that is read; the change of an unread property does not trigger the callback.
- The state that opens the detail area recomputes the screen that owns it through the local trigger.
- The departing view is not recomputed, it leaves the tree; the model does not treat this exit as a counted event.
- This lesson's trigger: **observed**.

#### Next Step

So far the source of the values was either a view's state or a model object. In the next lesson we will look at values whose source is the user's system settings: environment values such as the Dynamic Type size and the Reduce Motion preference, and which views they recompute together with the accessibility features.

### Accessibility

Source: https://academia.sh/en/courses/swiftui/accessibility

Measuring that Dynamic Type size and the Reduce Motion preference cause recomputation through the environment trigger, and that a label text supplied by the parent does so through the parent trigger.

In the previous lesson we saw that an item deleted from the model recomputes the list view through the observed trigger. In this lesson the change does not come from inside the app; it comes from the user's system settings. The question is again the same: **what triggered this recomputation?** The trigger examined in this lesson is **environment**.

Accessibility means that an interface can be used by users with different needs. The framework carries this information in three ways. The first is the label: `.accessibilityLabel(_:)` says what a view is introduced as to assistive technology. The second is the accessibility trait: `.accessibilityAddTraits(_:)` declares which role a view has, such as button, header or image. The third is the system preference: the user may have enlarged the text size, reduced motion or changed the color scheme, and these preferences reach views as environment values. Dynamic Type size is read with `@Environment(\.dynamicTypeSize)`, and the Reduce Motion preference with `@Environment(\.accessibilityReduceMotion)`. The first two paths are the view's own declaration; the third is a value that comes from outside the view. The real difference in terms of the trigger lies here.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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)")
    }
}
```

#### Three Paths, Three Reads

The fragment below has an article screen. The header reads the text size. The summary card uses a text given by the screen as its label, and opens either with or without an animation depending on the Reduce Motion preference. The label of the close button is a fixed string.

```swift
import SwiftUI

struct ArticleScreen: View {
    @State private var summary = "Summary"

    var body: some View {
        VStack {
            ArticleHeader()
            SummaryCard(label: summary)
            CloseButton()
        }
    }
}

struct ArticleHeader: View {
    @Environment(\.dynamicTypeSize) private var typeSize

    var body: some View {
        Text("Article")
            .font(typeSize.isAccessibilitySize ? .title : .headline)
            .accessibilityAddTraits(.isHeader)
    }
}

struct SummaryCard: View {
    let label: String
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var isOpen = false

    var body: some View {
        Text("Summary")
            .accessibilityLabel(label)
            .onTapGesture {
                if reduceMotion {
                    isOpen.toggle()
                } else {
                    withAnimation {
                        isOpen.toggle()
                    }
                }
            }
    }
}

struct CloseButton: View {
    var body: some View {
        Button("Close") {}
            .accessibilityLabel("Close the article")
    }
}
```

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. `ArticleScreen` owns `summary` and passes it to the card's label in its body; that is, it reads the value. `ArticleHeader` reads the `dynamicTypeSize` environment value. `SummaryCard` reads `summary` as the label parameter and the `accessibilityReduceMotion` environment value in its tap handler. `CloseButton` reads no value: its label is a string constant. The model builds the framework's dependency-tracking rule; it is not the framework itself.

We write three changes: the user enlarges the text size, turns on the Reduce Motion preference, and the screen's summary text changes. Changing an environment value is, in this lesson, a system setting, not a modifier.

```swift
// main.swift — text size, reduce motion and label text
let storage: [String: Storage] = [
    "summary": .state(owner: "ArticleScreen"),
    "dynamicTypeSize": .environment,
    "accessibilityReduceMotion": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ArticleScreen", parent: nil, reads: ["summary"]),
    ViewNode("ArticleHeader", parent: "ArticleScreen", reads: ["dynamicTypeSize"]),
    ViewNode("SummaryCard", parent: "ArticleScreen", reads: ["summary", "accessibilityReduceMotion"]),
    ViewNode("CloseButton", parent: "ArticleScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("text-enlarged", key: "dynamicTypeSize"),
    screen.change("motion-reduced", key: "accessibilityReduceMotion"),
    screen.change("summary-changed", key: "summary"),
].flatMap { $0 }

countCauses(recomputes)
```

```
text-enlarged: dynamicTypeSize changed | recomputed ArticleHeader environment | skipped ArticleScreen,SummaryCard,CloseButton
motion-reduced: accessibilityReduceMotion changed | recomputed SummaryCard environment | skipped ArticleScreen,ArticleHeader,CloseButton
summary-changed: summary changed | recomputed ArticleScreen local, SummaryCard parent | skipped ArticleHeader,CloseButton
local: 1
parent: 1
observed: 0
environment: 2
```

When the text size changes, only the header is recomputed, and the trigger is environment: the value is neither the header's state nor its parent's; it is an environment value supplied by the system. The Reduce Motion preference recomputes only the card in the same way. In the third line the label text changes: the screen that owns it is recomputed through the local trigger, and the card that reads the text as a parameter through the parent trigger. The card also reading an environment value does not affect this line, because the changed key determines the trigger.

One property of the environment trigger is that no view makes the change. For the text size there is no assignment, binding or callback; the value changes outside the view, and the view that reads it is recomputed. For this reason reading an environment value is a dependency declaration: the view's body becomes bound to that value.

The measure of an environment value stays the same throughout the course: whoever reads the value is the only one recomputed. In the accessibility context, though, this rule has one more consequence. If a view at the top of the tree reads a wide-reaching value such as the text size, not everything beneath that view is affected; only the reading view is recomputed. The views beneath are recomputed only if they also read the same value or receive a parameter that changes from the parent. That is, in this model, adding accessibility support does not make the whole screen recomputed; how the framework scales text internally was not measured. How this rule scales as the number of views grows was not measured in this lesson.

**IP13.** This measurement holds only for a tree in which `ArticleHeader` reads `dynamicTypeSize` and `SummaryCard` reads `accessibilityReduceMotion`. A single view that reads both values was not measured.

**IP14.** This measurement does not say which steps the text size is divided into, how much space the header takes on the screen, or the line count. These three things were not measured.

#### What a Label Reads

What a label reads depends on how you write it. The card's label came from the screen's state; when the screen changed the text, the card was recomputed. The label of the close button is a constant and is not bound to any value. It is important that a fixed label is read properly by assistive technology, but because the label reads no environment or state value, it does not trigger a recomputation either.

This distinction keeps accessibility from being counted as extra work. Labels and traits are mostly fixed texts or texts from the model, and the measurement shows that they bring no extra recomputation: only if the label's value comes from a state, the view is recomputed when that state changes. System preferences are separate. A screen reacting to the text size or the motion preference is limited to the views that read that environment value, and the rest of the views are unaffected.

**IP15.** This measurement rests on a pattern in which the label is given as a string. The case where the label comes from a localized key is the subject of the next lesson and was not measured here.

**IP16.** This measurement does not cover how assistive technology reads the label, the order, or how the traits are read out. These were not measured.

#### The Bounding Measurement

We show in a separate block that a label given as a fixed string reads no value. The tree has only the header and the close button. The text size changes; the header reads this value, the button does not. Then the Reduce Motion preference changes; nobody in this tree reads it.

```swift
// main.swift — a fixed label reads no value
let storage: [String: Storage] = [
    "dynamicTypeSize": .environment,
    "accessibilityReduceMotion": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ArticleHeader", parent: nil, reads: ["dynamicTypeSize"]),
    ViewNode("CloseButton", parent: nil, reads: []),
], storage: storage)

let recomputes = [
    screen.change("text-enlarged", key: "dynamicTypeSize"),
    screen.change("motion-reduced", key: "accessibilityReduceMotion"),
].flatMap { $0 }

print(recomputes.count)
```

```
text-enlarged: dynamicTypeSize changed | recomputed ArticleHeader environment | skipped CloseButton
motion-reduced: accessibilityReduceMotion changed | recomputed none | skipped ArticleHeader,CloseButton
1
```

The close button is in the `skipped` list on both lines: because its label is fixed, it is bound neither to the text size nor to any other environment value. On the second line there is no reading view, so the recomputed list says `none`. The `1` at the end shows that only one of the two changes produced a recomputation. This shows that a change of system preference does not always affect a view. How the button grows with the text size, how the framework scales text, and how the layout adapts to it were not measured; the model counts only which view reads the value.

#### Summary

- Accessibility information is carried in three paths: label, trait and system preference.
- Dynamic Type size and the Reduce Motion preference are environment values; only the view that reads them is recomputed through the environment trigger.
- If the label comes from the screen's state, the view that reads it is recomputed through the parent trigger when that state changes.
- A label given as a fixed string reads no value and causes no recomputation.
- This lesson's trigger: **environment**.

#### Next Step

One of the system preferences has not been covered yet: the user's language and region. In the next lesson we will measure which views the locale and the layout direction recompute as environment values, and through which channel the property of a model that formats a date arrives.

### Localization

Source: https://academia.sh/en/courses/swiftui/localization

Showing that the locale and layout direction cause recomputation through the environment trigger, that the model's date property does so through the observed trigger, and that formatting is not measured.

In the previous lesson we measured that the user's system preferences recompute the views that read them through the environment trigger. The user's language and region are a preference of the same kind. The question is again the same: **what triggered this recomputation?** The trigger examined in this lesson is **environment**; an observed example sits beside it.

Localization covers three separate subjects. The first is text: the translated counterpart of a phrase in the interface is shown in the user's language. The second is format: dates, numbers and currencies are written according to the habit of the user's region; the same day appears in one place in day-month order and in another in month-day order. The third is layout direction: some languages are written from right to left, and the interface layout is mirrored accordingly. The framework offers the locale for the first two and the layout direction for the third as environment values: `@Environment(\.locale)` and `@Environment(\.layoutDirection)`. Like the text size in the previous lesson, these two are values that come from outside the view.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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)")
    }
}
```

#### Locale, Layout Direction and Date

The fragment below is an event screen. The event's start date lives in an observable object. The header reads the locale. The date label reads both the date and the locale, because it formats the date according to that locale. The action row reads the layout direction and chooses the direction of an arrow mark accordingly. The footer note is raw text; localization is not applied to it.

```swift
import SwiftUI

@Observable
final class EventStore {
    var startDate = Date()
    var title = ""
}

struct EventScreen: View {
    let store: EventStore

    var body: some View {
        VStack {
            EventHeader()
            DateLabel(store: store)
            ActionRow()
            FooterNote()
        }
    }
}

struct EventHeader: View {
    @Environment(\.locale) private var locale

    var body: some View {
        Text(locale.identifier)
    }
}

struct DateLabel: View {
    let store: EventStore
    @Environment(\.locale) private var locale

    var body: some View {
        Text(store.startDate.formatted(.dateTime.day().month().locale(locale)))
    }
}

struct ActionRow: View {
    @Environment(\.layoutDirection) private var layoutDirection

    var body: some View {
        Text(layoutDirection == .rightToLeft ? "<" : ">")
    }
}

struct FooterNote: View {
    var body: some View {
        Text(verbatim: "v1")
    }
}
```

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. `EventScreen` reads no value directly; it only builds its children. `EventHeader` and `DateLabel` read the locale, and `DateLabel` also reads `store.startDate`. `ActionRow` reads the layout direction. `FooterNote` reads no value. The model builds the framework's dependency-tracking rule; it is not the framework itself.

We write three changes: the user changes the language, the layout direction turns right to left, and the event's date changes. Changing the environment values is, in this lesson, a system setting, not a modifier.

```swift
// main.swift — locale, layout direction and date
let storage: [String: Storage] = [
    "startDate": .model(object: "EventStore"),
    "locale": .environment,
    "layoutDirection": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("EventScreen", parent: nil, reads: []),
    ViewNode("EventHeader", parent: "EventScreen", reads: ["locale"]),
    ViewNode("DateLabel", parent: "EventScreen", reads: ["startDate", "locale"]),
    ViewNode("ActionRow", parent: "EventScreen", reads: ["layoutDirection"]),
    ViewNode("FooterNote", parent: "EventScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("language-changed", key: "locale"),
    screen.change("direction-changed", key: "layoutDirection"),
    screen.change("date-changed", key: "startDate"),
].flatMap { $0 }

countCauses(recomputes)
```

```
language-changed: locale changed | recomputed EventHeader environment, DateLabel environment | skipped EventScreen,ActionRow,FooterNote
direction-changed: layoutDirection changed | recomputed ActionRow environment | skipped EventScreen,EventHeader,DateLabel,FooterNote
date-changed: startDate changed | recomputed DateLabel observed | skipped EventScreen,EventHeader,ActionRow,FooterNote
local: 0
parent: 0
observed: 1
environment: 3
```

When the language changes, the two views that read the locale, the header and the date label, are recomputed through the environment trigger. The screen, the action row and the footer note are skipped. When the layout direction changes, only the action row is recomputed. When the date changes, only the date label is computed, and the trigger is observed: the date is a property of a model object. The same label can be recomputed through two separate channels because it reads values from both the environment and the model; the changed key determines which channel runs.

The final count gives three environment and one observed recomputation. The local and parent counts are zero, because in this tree no view receives a value that changes from a view above it, and no local state changes. The weight of the environment count comes from localization resting on system preferences.

**IP17.** This measurement holds only for a tree in which `DateLabel` reads the locale explicitly with `@Environment`. The framework resolving text constants according to the locale is a separate job and was not written as a read in the tree.

**IP18.** This measurement represents the moment the locale changes with a single label. Whether the app is restarted when the language changes was not measured.

#### What the Layout Direction Changes

In a language written from right to left, the interface layout is mirrored: the start of the line is on the right, and the forward and back arrows change direction. The framework does this itself with containers such as stacks and lists; but direction-dependent content such as an arrow mark is the view's own decision, and the `layoutDirection` environment value is read for this decision. When the direction changes, the view that reads this value is recomputed.

Two kinds of direction dependence must not be confused here. The part where the framework applies the mirroring itself does not require the view to read a value. The view choosing its own content according to the direction is a read, and therefore a dependency. The measurement counts only the second. Which views the first one recomputes in the tree is the framework's internal business and was not measured.

**IP19.** This measurement represents the change of only one of the two values of the layout direction. The measures of the mirrored layout, the alignment and the view order were not measured.

**IP20.** This measurement does not say in what format the date label is written on the screen. How the date is written was not measured.

#### The Bounding Measurement

Formatting is a function whose inputs are a value and a locale: in the function built in this lesson, the same value and the same locale always give the same text. Real calendar formatting depends on the calendar and the time zone as well as the locale; these were not measured. This function is not a reason for a recomputation; the reason is the change of the locale that is an input to the function. Real date and number formats rest on locale data, and this data is tied to the device, so they are not measured in this lesson. The block below shows with a small example that formatting is a function, and then tests that the model counts only the view that reads the locale.

```swift
// main.swift — formatting is a pure function
func dayLabel(day: Int, month: Int, monthFirst: Bool) -> String {
    monthFirst ? "\(month)/\(day)" : "\(day).\(month)"
}

print(dayLabel(day: 5, month: 3, monthFirst: false))
print(dayLabel(day: 5, month: 3, monthFirst: true))

let storage: [String: Storage] = [
    "locale": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("DateLabel", parent: nil, reads: ["locale"]),
    ViewNode("FooterNote", parent: nil, reads: []),
], storage: storage)

let recomputes = screen.change("locale-changed", key: "locale")
print(recomputes.count)
```

```
5.3
3/5
locale-changed: locale changed | recomputed DateLabel environment | skipped FooterNote
1
```

The first two lines are the same day written in two orders; this is not a real locale format, only an example of a pure function. The output of a real calendar format was not measured. The third line shows that the model counts only the view that reads the locale: `DateLabel` is recomputed through the environment trigger, `FooterNote` is skipped. The formatting function itself is not a view and has no place in the tree; the `1` on the last line is the number of recomputed views.

#### Summary

- Localization covers text, format and layout direction; the framework offers the locale and the layout direction for these as environment values.
- When the locale or the layout direction changes, only the view that reads that value is recomputed through the environment trigger.
- The model's date property recomputes the view that reads it through the observed trigger; the same view can be computed through both the environment and the observed channel.
- Formatting is a pure function; it has no place in the tree, and only the view that reads the locale is counted.
- This lesson's trigger: **environment**.

#### Next Step

The last lesson of this topic looks at how these triggers are seen in a fast feedback loop. A preview runs a view with a sample model; which sample model is supplied determines which of the values measured above can change in the preview. In the next lesson we will cover the sample model injection of the preview and iteration.

### Previews and Iteration

Source: https://academia.sh/en/courses/swiftui/previews-and-iteration

Showing how a preview recomputes a view through its sample model and overridden environment, why opening a preview does not count as a change, and a summary of the twenty-one lessons in the course.

In the previous lesson we measured that the locale and the layout direction cause recomputation through the environment trigger. In this lesson we look at the fast feedback loop: seeing a view on its own, without running the whole app. The question is again the same: **what triggered this recomputation?** The trigger examined in this lesson is **observed**.

A preview builds a view by itself. If the view needs a model, the preview gives it a sample model instead of real data. The preview can also determine the environment values the view reads; for example, it can force the dark color scheme. These two paths match the two triggers we measured earlier: a property of the sample model is observed, and an overridden environment value is the environment trigger. The preview itself is the work of a development tool and is not measured in this lesson; what is measured is the values the preview gives to the view.

The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.

```swift
// 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)")
    }
}
```

#### Sample Model and Overridden Environment

The fragment below is a profile screen. The name and the status are properties of an observable model. The avatar reads the color scheme from the environment. The two preview declarations build the same screen differently: one writes a short name into the model, the other turns the color scheme to dark.

```swift
import SwiftUI

@Observable
final class ProfileStore {
    var displayName = ""
    var status = ""
}

struct ProfileScreen: View {
    let store: ProfileStore

    var body: some View {
        VStack {
            AvatarView()
            NameLabel(store: store)
            StatusBadge(store: store)
        }
    }
}

struct AvatarView: View {
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Circle().fill(colorScheme == .dark ? Color.white : Color.black)
    }
}

struct NameLabel: View {
    let store: ProfileStore

    var body: some View {
        Text(store.displayName)
    }
}

struct StatusBadge: View {
    let store: ProfileStore

    var body: some View {
        Text(store.status)
    }
}

#Preview("Short name") {
    let store = ProfileStore()
    store.displayName = "Ada"
    return ProfileScreen(store: store)
}

#Preview("Dark") {
    ProfileScreen(store: ProfileStore())
        .environment(\.colorScheme, .dark)
}
```

This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. `ProfileScreen` reads no value directly; it passes the model to its children but does not touch its properties. `NameLabel` reads `displayName`, `StatusBadge` reads `status`, and `AvatarView` reads `colorScheme`.

We write three changes. The first two are properties of the sample model: the name and the status. The third is a change of the color scheme that the preview overrides.

```swift
// main.swift — sample model and overridden environment
let storage: [String: Storage] = [
    "displayName": .model(object: "ProfileStore"),
    "status": .model(object: "ProfileStore"),
    "colorScheme": .environment,
]

let preview = ViewTree(nodes: [
    ViewNode("ProfileScreen", parent: nil, reads: []),
    ViewNode("AvatarView", parent: "ProfileScreen", reads: ["colorScheme"]),
    ViewNode("NameLabel", parent: "ProfileScreen", reads: ["displayName"]),
    ViewNode("StatusBadge", parent: "ProfileScreen", reads: ["status"]),
], storage: storage)

let recomputes = [
    preview.change("sample-name-changed", key: "displayName"),
    preview.change("sample-status-changed", key: "status"),
    preview.change("overridden-mode-changed", key: "colorScheme"),
].flatMap { $0 }

countCauses(recomputes)
```

```
sample-name-changed: displayName changed | recomputed NameLabel observed | skipped ProfileScreen,AvatarView,StatusBadge
sample-status-changed: status changed | recomputed StatusBadge observed | skipped ProfileScreen,AvatarView,NameLabel
overridden-mode-changed: colorScheme changed | recomputed AvatarView environment | skipped ProfileScreen,NameLabel,StatusBadge
local: 0
parent: 0
observed: 2
environment: 1
```

When the sample model's name changes, only `NameLabel` is recomputed, and the trigger is observed. When the status changes, only `StatusBadge` is computed. When the overridden color scheme changes, only `AvatarView` is computed, and the trigger is environment. `ProfileScreen` is skipped in all three changes, because it only passes the model on and reads none of its properties. The final count gives two observed and one environment recomputation; the local and parent counts are zero, because the tree has no state owner or a parent view passing down a value.

This is the main measurement of the lesson: a preview gives the view values not from a real source but from its own sample model; when such a value changes, the view obeys the dependency rule just as it does in the running app. For this reason, seeing in a preview which properties a view reads is a cheap way to see in which changes that view will be recomputed.

**IP21.** This measurement treats the sample model as an object with two properties. The collections and nested objects of a real model were not measured.

**IP22.** This measurement represents the preview overriding the color scheme with the change of a single environment key. The preview overriding several environment values at the same time was not measured.

#### Iteration and Feedback

Iteration is changing the code and seeing the result again. A preview shortens this loop, because it builds the view only with its own sample data. Two separate things must not be confused here. Changing the source of the view may cause the preview to be rebuilt. Changing a property of the sample model causes a running view to be recomputed. The first is a code change and is not the subject of the model; the second is of the kind we have measured throughout this course.

The practical consequence is this. If a view is recomputed in a preview more often than expected, we look for the reason with one of these four questions: is the value the view's local state, a value passed by the parent, a property of a model, or a value from the environment? The preview does not answer this question, but it provides a fast environment in which to try the answer.

**IP23.** This measurement does not represent the duration of the iteration loop in any way. How fast the preview refreshes was not measured.

**IP24.** This measurement does not count the rebuilding of a preview from source as a change. The effect of source edits on the tree was not measured.

#### The Bounding Measurement

Opening a preview is not a change; it is a new starting state. Every preview builds the tree from scratch. The model builds this as a new `ViewTree` and does not count a recomputation. In the block below we build two previews for the same views. The opening of one leaves no record. The change is made in only one of them and does not affect the other.

```swift
// main.swift — a preview is a new starting state
let storage: [String: Storage] = [
    "displayName": .model(object: "ProfileStore"),
]

let nodes = [
    ViewNode("ProfileScreen", parent: nil, reads: []),
    ViewNode("NameLabel", parent: "ProfileScreen", reads: ["displayName"]),
]

let first = ViewTree(nodes: nodes, storage: storage)
let second = ViewTree(nodes: nodes, storage: storage)

let firstRun = first.change("sample-name-changed", key: "displayName")
print(firstRun.count)
print(second.nodes.count)
```

```
sample-name-changed: displayName changed | recomputed NameLabel observed | skipped ProfileScreen
1
2
```

That building leaves no record is read from the code itself: the `ViewTree` initializer only stores the nodes and the storage table, and a recomputation is produced only by a `change` call. The first line says that the change recomputed only `NameLabel` in the first tree and skipped `ProfileScreen`; the second line is `1`, the number of those recomputations. The third line shows that the second tree is built from scratch, with two nodes and unchanged: a change in one preview is not reflected in the other.

#### Course Wrap-Up

Over the course, twenty-one lessons each measured the reason for a recomputation. The table below shows the change each lesson measured, the lesson's trigger, and what its bounding measurement could not measure.

| Lesson | Change | Trigger | Bounding measurement |
|---|---|---|---|
| Declarative Syntax | six changes | all four (2/2/2/2) | a value that is not stored produces no trigger |
| Views and Modifiers | font changed | environment | a child that sets its own font does not read the environment |
| Stack Layouts | spacing changed | parent | a sibling layer cannot read the value |
| The Layout System | became compact | parent | the proposed width is out of scope |
| Grids and Geometry | class changed | environment | the reader's size is out of scope |
| Lists and Forms | title changed | observed | a single row leaves the other rows skipped |
| Visual Components | color scheme changed | environment | a static image reads no value |
| View Builders | title changed | parent | the closure is not a node in the tree |
| Local State | tapped | local | the sibling's state cannot be read |
| Bindings | child wrote | parent | the rule does not read the writer |
| Observable Objects | book added | observed | object-level notification is broader |
| Environment Values | mode changed | environment | an override is not measured with a single record |
| The Navigation Stack and Path | screen pushed | local | the pushed destination is a new node |
| Presentation and Dismissal | sheet opened | local | the dismiss action is not a value |
| Tabs and Toolbars | switched to archive | local | an unselected tab is skipped |
| Gestures | drag ended | local | drag velocity is not stored |
| Implicit and Explicit Animations | panel opened | local | the animation difference is not measured |
| Transitions | message deleted | observed | the departing view is not in the tree |
| Accessibility | text enlarged | environment | a fixed label reads no value |
| Localization | language changed | environment | formatting is a pure function |
| Previews and Iteration | sample name changed | observed | a preview is a new starting state |

The trigger distribution of the twenty lessons other than the opening lesson is as follows: local 6, parent 4, observed 4, environment 6. The core in the opening lesson additionally gave a 2/2/2/2 distribution. The two are not the same thing: the core counts eight recomputations, while the distribution counts the triggers of the twenty lessons other than the opening lesson.

#### Summary

- A preview gives a view a sample model and, when needed, overridden environment values; what is measured is these values.
- When a sample model property changes, the view that reads it is recomputed through the observed trigger.
- When an environment value overridden by the preview changes, the view that reads it is recomputed through the environment trigger.
- Opening a preview is a new starting state, not a change; the tree is built from scratch and no recomputation is counted.
- Each of the twenty-one lessons measured one trigger; the bounding measurements showed what the model cannot measure.
- This lesson's trigger: **observed**.

#### Next Step

This course ended with measuring why a declarative interface is recomputed. The next course will change the subject area: we will grasp a screen written with an imperative interface framework and build a bridge between the two. The question and the four triggers we learned in this course will be the language we rely on when comparing the two sides of that bridge.

