Skip to content
academia.sh

Lesson 03 / 21

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.

Published Updated

In this lesson

  • Tells the horizontal, vertical and depth stacks apart by how they arrange their children and which parameters they take
  • Measures that the spacing and axis coming from the stack owner's state produce the parent trigger for the children
  • Separates a layout direction coming from the environment from a value in the stack owner's state
  • Reads in the model that siblings in a depth stack cannot read each other's state
Contents

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.

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

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

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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close