---
title: 'The Layout System'
source: 'https://academia.sh/en/courses/swiftui/layout-system'
course: 'UI Development with SwiftUI'
language: en
updated: '2026-10-21T05:03:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Reads the three steps of the layout system (proposal, choice, placement) in a source snippet'
    - 'Measures that the parent value that determines the proposed size descends to the children as the parent trigger'
    - 'Shows that an environment value such as the text size preference produces a separate trigger'
    - 'Measures that a size proposal is not a measured state value and that the model leaves it out of scope'
---

# The 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.
