Lesson 05 / 21
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.
Published Updated
In this lesson
- Reads in a source snippet how a grid builds its cell layout from the column definitions
- Measures that the horizontal size class determines the number of columns as an environment value
- Shows that the parent state that supplies the grid data descends to the cells as the parent trigger
- Measures that the size a geometry reader provides is not state and that the model leaves it out of scope
Contents
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.
// 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.
// 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.
// 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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.