Skip to content
academia.sh

Lesson 12 / 21

Environment Values

Measuring that an environment value recomputes only its reader along the tree without touching the views in between, that a property of an object placed in the environment triggers observation rather than environment, and how overriding in a subtree is built in the model.

Published Updated

In this lesson

  • Measures that an environment value recomputes a reader deep in the tree while skipping the views in between
  • Shows that a system environment value and a custom environment key produce the same trigger
  • Separates the fact that a change to a property of an object placed in the environment counts as observation, not environment
  • Sees that overriding in a subtree requires a separate key in a single-key model
Contents

In the previous lesson an object lived on its own, and to reach it we passed the object to every child as a parameter. The views in between had to carry it even when they had no interest in it. The environment removes this carrying: a value is placed at one point in the tree, and every view beneath it can read it directly. The trigger examined in this lesson is environment.

The measurement again uses the core file.

// 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)")
    }
}

Storage.environment says that a value belongs to no view and is a broadcast flowing down from that point in the tree. The cause function gives the environment trigger to every view that reads this key, without asking the ownership question. The model builds the framework’s dependency-tracking rule; it is not the framework itself.

A Reader Deep in the Tree

The framework supplies some environment values itself: the color scheme, the text size setting, the locale. The application can add its own keys too. A custom key is defined with an extension on EnvironmentValues, and the @Entry macro builds the key together with its default value. A view reads the value with @Environment; the value is placed in an ancestor view with the .environment modifier.

import SwiftUI

extension EnvironmentValues {
    @Entry var textScale: Int = 1
}

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticleView()
            SidebarView()
        }
        .environment(\.textScale, 2)
    }
}

struct ArticleView: View {
    var body: some View {
        VStack {
            ParagraphView()
            CaptionView()
        }
    }
}

struct ParagraphView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Scale \(textScale)")
    }
}

struct CaptionView: View {
    @Environment(\.textScale) private var textScale

    var body: some View {
        Text("Caption \(textScale)")
    }
}

struct SidebarView: View {
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        Text(colorScheme == .dark ? "Dark" : "Light")
    }
}

ArticleView reads no environment value; it only hosts its two children. ParagraphView and CaptionView read the custom textScale key, and SidebarView reads the system’s colorScheme value. In the tree these three reads are in reads. Two changes are applied: one to the value of the custom key, one to the system’s color scheme. The first happens when the application changes the value in its .environment modifier, the second when the user changes the system setting; both are environment values.

// main.swift — an environment value recomputes only its reader, without touching what is in between
let storage: [String: Storage] = [
    "textScale": .environment,
    "colorScheme": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticleView", parent: "ReaderScreen", reads: []),
    ViewNode("ParagraphView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("CaptionView", parent: "ArticleView", reads: ["textScale"]),
    ViewNode("SidebarView", parent: "ReaderScreen", reads: ["colorScheme"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("mode-changed", key: "colorScheme"),
].flatMap { $0 }

countCauses(recomputes)
scale-changed: textScale changed | recomputed ParagraphView environment, CaptionView environment | skipped ReaderScreen,ArticleView,SidebarView
mode-changed: colorScheme changed | recomputed SidebarView environment | skipped ReaderScreen,ArticleView,ParagraphView,CaptionView
local: 0
parent: 0
observed: 0
environment: 3

In the scale-changed row, two views, ParagraphView and CaptionView, were recomputed; both are descendants, and the ancestor ReaderScreen is the view that places the value. ArticleView was in between and was skipped: because it does not read the environment value, the change did not touch it. In the mode-changed row only SidebarView was recomputed. All three recomputations are environment; in the count, environment is three and the rest are zero.

The gain of the environment is in that skipped row. If the value were carried two levels down the tree by parameter, ArticleView would have to take it as a parameter and pass it on, and it would itself count as a reader when the value changed. In the environment, the view in between does not know the value and is therefore not affected by the change.

SN10. This measurement covers two environment keys and two changes. The system’s color scheme changes in reality with the user’s setting; in the measurement the setting was represented as a key change, and how the framework delivers the change of the setting was not measured.

An Object Placed in the Environment

Not only simple values are placed in the environment; an observable object can be placed too. The object is provided with .environment(session) and read with @Environment(SessionStore.self). In this case two separate things can change: the object itself, or a property of the object.

import SwiftUI
import Observation

@Observable
final class SessionStore {
    var unreadCount = 0
}

struct InboxBadge: View {
    @Environment(SessionStore.self) private var session

    var body: some View {
        Text("Unread: \(session.unreadCount)")
    }
}

InboxBadge takes the object from the environment, but what it reads in its body is the object’s unreadCount property. What is tracked is therefore not the environment itself but an object property. In the tree these two distinctions are written as two different storages: textScale is environment, unreadCount is an object property.

// main.swift — a property of an object placed in the environment triggers observation, not environment
let storage: [String: Storage] = [
    "textScale": .environment,
    "unreadCount": .model(object: "SessionStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ParagraphView", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("InboxBadge", parent: "ReaderScreen", reads: ["unreadCount"]),
], storage: storage)

let recomputes = [
    screen.change("scale-changed", key: "textScale"),
    screen.change("unread-changed", key: "unreadCount"),
].flatMap { $0 }

countCauses(recomputes)
scale-changed: textScale changed | recomputed ParagraphView environment | skipped ReaderScreen,InboxBadge
unread-changed: unreadCount changed | recomputed InboxBadge observed | skipped ReaderScreen,ParagraphView
local: 0
parent: 0
observed: 1
environment: 1

Two changes produced two recomputations, and the triggers differ: scale-changed is environment, unread-changed is observed. InboxBadge reads an object it reaches through the environment, but what recomputes it is not the environment but the object property. An object taken from the environment is only an access path; the place where the value is stored determines the trigger. This matches the rule of the third lesson: however and by whomever an object property is read, the trigger is observation.

The counts: observed one, environment one.

SN11. This measurement is only for a change to a property of the object. The case where the object in the environment is replaced entirely by another object, that is, a change of the environment value itself, was not measured; the environment trigger would be expected in that case, but the model did not represent it separately as a key change.

The Bounding Measurement

An environment value can be overridden at a point in the tree: another value is placed for the same key in a subtree, and the views in that subtree read the overriding value rather than the upper one. A change to the upper value does not concern this subtree. The model, however, keeps a single storage row for each key; the same key cannot have two different values.

import SwiftUI

struct ReaderScreen: View {
    var body: some View {
        HStack {
            ArticlePane()
            SidePane()
                .environment(\.textScale, 3)
        }
        .environment(\.textScale, 2)
    }
}

In the measurement below, the override is therefore represented with two separate keys: textScale carries the upper value, and paneTextScale carries the overriding value in the side pane. We write which view reads which by looking at the source; the model cannot find this out by itself.

// main.swift — an override in a subtree is a separate key in the model
let storage: [String: Storage] = [
    "textScale": .environment,
    "paneTextScale": .environment,
]

let screen = ViewTree(nodes: [
    ViewNode("ReaderScreen", parent: nil, reads: []),
    ViewNode("ArticlePane", parent: "ReaderScreen", reads: ["textScale"]),
    ViewNode("SidePane", parent: "ReaderScreen", reads: ["paneTextScale"]),
], storage: storage)

let recomputes = [
    screen.change("upper-scale-changed", key: "textScale"),
    screen.change("side-scale-changed", key: "paneTextScale"),
].flatMap { $0 }

countCauses(recomputes)
upper-scale-changed: textScale changed | recomputed ArticlePane environment | skipped ReaderScreen,SidePane
side-scale-changed: paneTextScale changed | recomputed SidePane environment | skipped ReaderScreen,ArticlePane
local: 0
parent: 0
observed: 0
environment: 2

A change to the upper value recomputed only ArticlePane, and a change to the overriding value recomputed only SidePane. Both rows are environment. But it was the two keys we set up, not the measurement, that determined this result: if we had written a textScale read for SidePane in the tree, the model, not knowing overrides, would have recomputed it on the upper change as well. So the model can represent an override, but it cannot distinguish one. That an override shadows the upper value in the real framework is documented behavior; it is not something measured in this lesson.

SN12. This measurement is only for a representation with two keys. The case where the same key is read with two values in the upper and lower subtree, the case where overriding is nested across several layers, and the case where the overriding value is derived from the upper value were not measured.

Summary

  • An environment value is placed at one point in the tree; a reader view beneath it reads it without touching the views in between.
  • When the value changes, only the view that reads it is recomputed and the trigger is environment; an in-between view that does not read it is skipped.
  • A system environment value and a custom @Entry key produce the same trigger.
  • When a property of an object placed in the environment changes, the trigger is observation, not environment; the environment is only the access path.
  • An override in a subtree is represented as a separate key in a single-key model; the model cannot distinguish an override.
  • This lesson’s trigger: environment.

Next Step

At this point all four triggers have been measured, and in every one of them what changed was a value. In the next lesson the screen itself is tied to a value: a path that says which screen will be shown. The navigation stack and its path are a state, and the question of which views a change to that state recomputes is again the same trigger question.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close