---
title: 'View Builders'
source: 'https://academia.sh/en/courses/swiftui/view-builders'
course: 'UI Development with SwiftUI'
language: en
updated: '2026-10-21T05:03:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Reads in a source snippet how a view builder gathers multiple expressions into a single view'
    - "Measures that a condition inside builder content produces the parent trigger when it depends on the parent's state"
    - "Shows that a builder component's own pressed state produces the local trigger"
    - 'Measures that a content closure is not a node in the tree and that its read belongs to the view that builds the content'
---

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