Lesson 13 / 21
The Navigation Stack and Path
Measuring that the navigation path is a state held at the root, that pushing and going back produce the same trigger, and that a value pushed onto the path is not a view.
Published Updated
In this lesson
- Shows with a measurement that the navigation path is a local state held at the root
- Separates the fact that pushing a screen onto the path and going back produce the same recomputation set
- Measures that an object property shown by the destination view produces the observed trigger
- Explains that a value pushed onto the path is not a view, and that the destination enters the tree as a new node
Contents
The previous four lessons looked at where state lives: in a view, at the end of a binding, in an object, and in the environment. In this lesson what the state points to changes. Moving between screens is the change of a value that says which screen will be shown, and this value also sits in one of the storage places above. A navigation stack is the structure that holds the screens the user has opened on top of one another; the path is the sequence of values that describes the contents of this stack. The trigger examined in this lesson is local: the root view that holds the path is the owner of the value.
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 has no type specific to navigation. The path is written as a view’s state with Storage.state(owner:); the views that read the path carry the path key in reads. The model builds the framework’s dependency-tracking rule; it is not the framework itself.
The Path Sits at the Root
A navigation stack has two parts. The first is the NavigationStack at the root: we give it the path state through a binding. The second is the .navigationDestination(for:destination:) modifier, which says which view corresponds to each value on the path. In the source fragment below the path is an array of integers, and each integer is the identifier of an item.
import SwiftUI
struct RootScreen: View {
@State private var path: [Int] = []
@State private var store = ItemStore()
var body: some View {
NavigationStack(path: $path) {
VStack {
ItemList(path: $path)
SettingsButton()
}
.navigationDestination(for: Int.self) { itemID in
ItemDetail(itemID: itemID, store: store, path: $path)
}
}
}
}
struct ItemList: View {
@Binding var path: [Int]
var body: some View {
Button("Open, depth \(path.count)") {
path.append(7)
}
}
}
The path belongs to RootScreen. The body gives the path to the NavigationStack through a binding and passes the same binding on to the child views. ItemList both reads and writes the path through this binding; it shows the depth in the button’s text. SettingsButton does not touch the path. The tree carries the type names and the property name in the source fragment. Two changes are applied: a screen being pushed and going back.
// main.swift — the navigation path sits at the root, pushing and going back produce the same trigger
let storage: [String: Storage] = [
"path": .state(owner: "RootScreen"),
]
let screen = ViewTree(nodes: [
ViewNode("RootScreen", parent: nil, reads: ["path"]),
ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
ViewNode("SettingsButton", parent: "RootScreen", reads: []),
], storage: storage)
let recomputes = [
screen.change("screen-pushed", key: "path"),
screen.change("went-back", key: "path"),
].flatMap { $0 }
countCauses(recomputes)
screen-pushed: path changed | recomputed RootScreen local, ItemList parent | skipped SettingsButton went-back: path changed | recomputed RootScreen local, ItemList parent | skipped SettingsButton local: 2 parent: 2 observed: 0 environment: 0
Two changes, four recomputations. The two rows are identical: RootScreen is local, ItemList is parent, SettingsButton was skipped. A user tapping a button starts the push, and the system’s back gesture starts going back; the one who writes the change is different each time, but the owner of the path is the same, so the result is the same. This is an application of the rule of the second lesson: the rule does not read the writer. In the count, local is two, parent is two, observed and environment are zero.
Holding the path at the root is a design decision. If the path were held in a child view, the root and the siblings that do not read the path would have no access to it; this is the reason for lifting the state to an ancestor. The navigation path sits at the root from the start, because the decision of which screen to show depends on all the screens.
SN13. This measurement covers two changes and a single path key. The case where several values of the path change at once, for example emptying the whole stack, was not measured. How the NavigationStack finds the destination that corresponds to a value written to the path was not measured either.
The Object the Destination Shows
The path is a sequence of values: it is an integer, not a view. When a value is pushed onto the path, the framework calls the closure defined with .navigationDestination with that value and puts the resulting view on the stack. The destination view also reads an object’s property when needed. In the source fragment below, let the destination be ItemDetail, which reads the item’s title from an observable object; the path is also passed to it as a binding.
import SwiftUI
import Observation
@Observable
final class ItemStore {
var itemTitle = "Draft"
}
struct ItemDetail: View {
let itemID: Int
let store: ItemStore
@Binding var path: [Int]
var body: some View {
VStack {
Text("\(itemID): \(store.itemTitle)")
Button("Depth \(path.count)") { path.append(itemID + 1) }
}
}
}
ItemDetail reads two things: the length of the path and the object’s title. This destination screen is now open and sits on the stack; the tree builds the trio of RootScreen, ItemList and ItemDetail. ItemDetail sits under the root, because the root builds the destination closure.
// main.swift — the object the destination shows is observed, the destination reading the path produces parent
let storage: [String: Storage] = [
"path": .state(owner: "RootScreen"),
"itemTitle": .model(object: "ItemStore"),
]
let screen = ViewTree(nodes: [
ViewNode("RootScreen", parent: nil, reads: ["path"]),
ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
ViewNode("ItemDetail", parent: "RootScreen", reads: ["path", "itemTitle"]),
], storage: storage)
let recomputes = [
screen.change("title-changed", key: "itemTitle"),
screen.change("second-screen-pushed", key: "path"),
].flatMap { $0 }
countCauses(recomputes)
title-changed: itemTitle changed | recomputed ItemDetail observed | skipped RootScreen,ItemList second-screen-pushed: path changed | recomputed RootScreen local, ItemList parent, ItemDetail parent | skipped none local: 1 parent: 2 observed: 1 environment: 0
title-changed recomputed only ItemDetail, and it is observed: the title is an object property and has nothing to do with the root that holds the path. second-screen-pushed recomputed three views at once: the root is local, ItemList and ItemDetail are parent. Because the destination screen is already on the stack, it reads the path and is recomputed when the path changes. The counts: local one, parent two, observed one.
Here two triggers meet in the same view. In the same measurement ItemDetail received the observed trigger once and the parent trigger once. Which change the destination view experiences depends on which value it reads and where the value is stored; navigation does not change that.
SN14. This measurement is for a destination that reads the path and a single object property. That a destination which never reads the path would not be recomputed when the path changes comes from the model’s rule alone, unmeasured; whether the destination closure reads the path was not measured.
The Bounding Measurement
What is pushed onto the path is not a view but a value. Changing the path does not add a view to the tree; the framework builds the destination that corresponds to the value. For this reason, at the moment it first appears the destination is a new node, and that first appearance is not a recomputation. A recomputation is the body of a view that already existed being read again when a value changes. The measurement below applies the same change to two trees: one where the destination is not yet on the stack and one where it is.
// main.swift — a value pushed onto the path is not a view, the destination is a new node
let storage: [String: Storage] = [
"path": .state(owner: "RootScreen"),
]
let beforePush = ViewTree(nodes: [
ViewNode("RootScreen", parent: nil, reads: ["path"]),
ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
], storage: storage)
let afterPush = ViewTree(nodes: [
ViewNode("RootScreen", parent: nil, reads: ["path"]),
ViewNode("ItemList", parent: "RootScreen", reads: ["path"]),
ViewNode("ItemDetail", parent: "RootScreen", reads: ["path"]),
], storage: storage)
let recomputes = [
beforePush.change("pushed-without-target", key: "path"),
afterPush.change("pushed-with-target", key: "path"),
].flatMap { $0 }
countCauses(recomputes)
pushed-without-target: path changed | recomputed RootScreen local, ItemList parent | skipped none pushed-with-target: path changed | recomputed RootScreen local, ItemList parent, ItemDetail parent | skipped none local: 2 parent: 3 observed: 0 environment: 0
In the first row ItemDetail is neither among the recomputed nor among the skipped. There was no such node in the tree at that moment; the push will create it, but the model does not count the first body of a newly created node as a trigger. In the second row the destination was already on the stack and read the path, so it was recomputed with the parent trigger. In the count, local is two and parent is three; the push action in the first row did not count the destination’s first appearance.
This distinction is a warning for the lesson: in navigation, “a screen opened” and “a screen was recomputed” are not the same event. A screen’s first opening is a construction; a recomputation happens only after the screen exists, when a value it reads changes.
SN15. This measurement compares two cases, where the destination does not exist and where it does. How many steps the framework takes for the destination’s first body computation, and whether the destination keeps its state when it is removed from the stack and built again, were not measured.
Summary
- The navigation path is a state held at the root; its owner is the root view.
- When the path changes, the root that holds it is recomputed with the local trigger and the child views that read the path with the parent trigger.
- Pushing a screen and going back produce the same recomputation set; who the writer is does not change the trigger.
- An object property shown by the destination view produces observed, and the path it reads produces the parent trigger.
- What is pushed onto the path is a value, not a view; the destination is a new node, and its first appearance does not count as a recomputation.
- This lesson’s trigger: local.
Next Step
In this lesson the path added a screen to the stack, and the screen became part of the stack. Not every screen enters the stack: some open temporarily over the current screen and close when the work is done. In the next lesson a sheet, an alert and a full-screen cover are again tied to a value; presentation state and dismissal work like the path but with a different ownership.
To keep your progress and take notes, Log in
My notes
Log in to take notes.