Lesson 02 / 21
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.
Published Updated
In this lesson
- Separates a view from a modifier by the fact that a modifier returns a new view
- Measures that font and color modifiers placed on a container spread to the subtree as environment values
- Separates the triggers of a value the parent passes as a parameter from a value read from the environment
- Reads in a source snippet how modifier order changes the result
Contents
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.
// 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.
// 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.
// 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.
// Source snippet: padding first, background after
import SwiftUI
struct PaddedBadge: View {
var body: some View {
Text("New")
.padding()
.background(Color.yellow)
}
}
// 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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.