Skip to content
academia.sh

Lesson 15 / 21

Tabs and Toolbars

Measuring that the tab selection lives at the root, that content which only writes the selection through a binding does not count as a reader, and that a tab that is not selected is skipped because it does not read the selection.

Published Updated

In this lesson

  • Shows with a measurement that the tab selection is a local state held at the root
  • Separates the fact that tab content which takes the selection through a binding and only writes it does not count as a reader
  • Measures that tying a toolbar button's enabled state to an object property produces the observed trigger
  • Explains that a tab that is not selected is skipped as long as it does not read the selection
Contents

The tab bar and the toolbar are two separate shells that stand below and above the screen. The first says which section is visible, the second which actions that section offers. Both rest on a value. In the tab bar this is the selection, a value held at the root; in the toolbar, whether a button is enabled usually comes from a property of an object. The trigger examined in this lesson is local: the selection sits at the root and the root reads it; the tab contents may take the selection through a binding, but they do not read it in their bodies.

The measurement 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)")
    }
}

The core writes the selection as a state key and the object property that carries the button’s enabled state as a model key.

The Selection Sits at the Root

A TabView takes the selection as a binding. Each tab’s content carries a value with .tag; the framework matches the selection value against the tag and decides which content to show. In the source fragment below the root is the owner of the selection and passes it on to the contents as a binding.

import SwiftUI

struct ShellScreen: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            InboxTab(selectedTab: $selectedTab)
                .tag(0)
            ArchiveTab()
                .tag(1)
        }
    }
}

struct InboxTab: View {
    @Binding var selectedTab: Int

    var body: some View {
        Button("Go to archive") { selectedTab = 1 }
    }
}

struct ArchiveTab: View {
    var body: some View {
        Text("Archive")
    }
}

The selection is in ShellScreen. InboxTab takes the selection through a binding, because it must be able to switch to another tab with a button; but it does not read the value in its body, it only writes it in the button’s action. The rule of the second lesson holds here too: a view that only writes does not enter the readers. ArchiveTab never takes the selection. First a selection change is measured with the root and two tabs: from the inbox to the archive, and from the archive back.

// main.swift — the tab selection sits at the root, a tab that does not read the selection is skipped
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("switched-to-archive", key: "selectedTab"),
    screen.change("back-to-inbox", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
switched-to-archive: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab
back-to-inbox: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab
local: 2
parent: 0
observed: 0
environment: 0

Two changes, two recomputations; in each row only the root is local, and both tabs are skipped. The count is local two.

Neither tab reads the selection value; they therefore do not need to know which tab is showing. The framework matches the selection against the tags, and the contents themselves do not take part in this matching. The content of a tab is skipped as long as it does not read the selection in its body. InboxTab carries the binding, but it was skipped because it only writes; content that read the selection in its body would get the parent trigger, and there is no such content in this tree.

SN19. This measurement covers two changes and two tabs. Whether the framework never builds the content of a tab that is not selected or keeps it in memory was not measured; the measurement only counts which view was recomputed.

The Toolbar Button Reads From an Object

Whether a button in the toolbar is enabled depends on the object the section shows: if there is a selected message in the inbox, the “Delete” button is enabled. The button is defined inside .toolbar and takes its enabled state from an object property. This is an object property; it concerns not the binding of the second lesson but the observed trigger of the third.

import SwiftUI
import Observation

@Observable
final class MailboxStore {
    var hasSelectedMessage = false
}

struct InboxToolbar: ToolbarContent {
    let store: MailboxStore

    var body: some ToolbarContent {
        ToolbarItem {
            Button("Delete") { }
                .disabled(!store.hasSelectedMessage)
        }
    }
}

In the measurement there are the root, the inbox tab, the toolbar button and the archive tab. Only the toolbar button reads the object property; the selection is read only at the root.

// main.swift — the enabled state of the toolbar button depends on an object property
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
    "hasSelectedMessage": .model(object: "MailboxStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("DeleteButton", parent: "InboxTab", reads: ["hasSelectedMessage"]),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("message-selected", key: "hasSelectedMessage"),
    screen.change("switched-to-archive", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
message-selected: hasSelectedMessage changed | recomputed DeleteButton observed | skipped ShellScreen,InboxTab,ArchiveTab
switched-to-archive: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,DeleteButton,ArchiveTab
local: 1
parent: 0
observed: 1
environment: 0

message-selected recomputed only the toolbar button, and it is observed: the root, the inbox and the archive were skipped. switched-to-archive, on the other hand, recomputed only the root as local; the inbox, the button and the archive do not read the selection and were skipped. The count is local one, observed one.

The sets of the two changes are disjoint: one touches the button, the other only the root. This is the measurable result of where the state lives: because the selection is at the root, the root is recomputed too; because the enabled state is in an object, the root is never touched.

SN20. This measurement covers two keys and a single toolbar button. The toolbar layout changing when the tab changes, and the case where the button is shared across several tabs, were not measured.

The Bounding Measurement

When the selected tab changes, the content of the previous tab leaves the screen, but this does not mean it received a recomputation. For a view to be recomputed, a value it reads must change. If a tab that is not selected does not read the selection, it is skipped every time the selection changes. The measurement shows this with three tabs: the first takes the selection through a binding but only writes it, and the other two never take the selection.

// main.swift — the content of a tab that is not selected is skipped unless it reads the selection
let storage: [String: Storage] = [
    "selectedTab": .state(owner: "ShellScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("ShellScreen", parent: nil, reads: ["selectedTab"]),
    ViewNode("InboxTab", parent: "ShellScreen", reads: []),
    ViewNode("ArchiveTab", parent: "ShellScreen", reads: []),
    ViewNode("SettingsTab", parent: "ShellScreen", reads: []),
], storage: storage)

let recomputes = [
    screen.change("switched-to-settings", key: "selectedTab"),
].flatMap { $0 }

countCauses(recomputes)
switched-to-settings: selectedTab changed | recomputed ShellScreen local | skipped InboxTab,ArchiveTab,SettingsTab
local: 1
parent: 0
observed: 0
environment: 0

All three tabs were skipped regardless of both the previous and the next selection; the inbox tab, which carries the binding, is among them. Only the root was recomputed. The count is local one.

The design consequence follows from here: in this model what decides a recomputation is not that a binding is given but that the value is read in the body. Giving a binding to content that only writes the selection does not move the computation out of the root; the moment the content starts reading the selection in its body, it gets the parent trigger. Whether the binding itself has a cost in the framework was not measured.

SN21. This measurement is for a single selection change and three tabs. What the framework does when tabs that are not selected leave the screen, for example preserving the view’s state, was not measured.

Summary

  • The tab selection is a state held at the root; when the selection changes, the root gets the local trigger.
  • Tab content that takes the selection through a binding and only writes it does not count as a reader and is skipped; content that read the selection in its body would get the parent trigger.
  • If the enabled state of a toolbar button comes from an object property, it produces the observed trigger and does not touch the root.
  • A tab that is not selected is skipped on every selection change as long as it does not read the selection.
  • This lesson’s trigger: local.

Next Step

This topic measured where state lives, whom it recomputes, and how it is carried between screens. The next topic looks at the finger itself: during a drag the offset value changes on every frame, and that change is also a link in the same chain. The first lesson, Gestures, measures which view a drag offset recomputes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close