Lesson 18 / 21
Transitions
Measuring that an item deleted from the model recomputes the list view through the observed trigger, that the state opening a detail area recomputes the screen through the local trigger, and that the departing view is not computed.
Published Updated
In this lesson
- Defines a transition as the form chosen for a view's entry into and exit from the tree
- Measures that an item deleted from the model recomputes the list through the observed trigger
- Measures that observation tracking reacts only to the property that is read
- Shows that the departing view is not recomputed but leaves the tree
Contents
In the previous lesson we measured that an animation wraps the change, does not alter the trigger, and leaves the view in the tree. In this lesson the view does not stay in the tree: it either enters it or leaves it. The question is again the same: what triggered this recomputation? The trigger examined in this lesson is observed; a local example sits beside it.
A transition is a declarative definition that selects how a view is reflected as it is added to the tree or removed from it. A view takes part in the body depending on a condition; when the condition changes, the view arrives or goes. The .transition(_:) modifier selects a form for this entry and exit: fade, slide, scale or a combination of them. AnyTransition.asymmetric(insertion:removal:) gives the entry and the exit separate forms. For a transition to be seen, the insertion or removal change must be wrapped in an animation; without one, the view arrives or goes without a transition. For this reason transitions continue the second lesson: the animation wraps the change, and the transition shapes the effect of that change on the tree.
The measurement uses the core file; the core arrives in every lesson with the same file name and the same content.
// 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 Detail Area and the List
The fragment below sets up an inbox. The messages live in an observable object, and the list reads this object. The screen shows a detail area depending on the isShowingDetail state, and the button wraps this change in an animation.
import SwiftUI
@Observable
final class InboxStore {
var messages: [String] = []
var unreadCount = 0
}
struct InboxScreen: View {
let store: InboxStore
@State private var isShowingDetail = false
var body: some View {
VStack {
MessageList(store: store)
Button("Details") {
withAnimation {
isShowingDetail.toggle()
}
}
if isShowingDetail {
DetailBanner()
.transition(.opacity)
}
}
}
}
struct MessageList: View {
let store: InboxStore
var body: some View {
List(store.messages, id: \.self) { message in
Text(message)
}
}
}
struct DetailBanner: View {
var body: some View {
Text("Details")
}
}
This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. MessageList reads store.messages; because InboxStore is an observable object, this read is through the observation channel. InboxScreen reads isShowingDetail in the condition in its body and owns the value. DetailBanner reads no value. The model builds the framework’s dependency-tracking rule; it is not the framework itself.
Now we write two changes: a message being deleted from the model, and the detail area opening. The names in the tree are the same as the type names in the source fragment, and the keys are the property names.
// main.swift — a deleted item and an opening detail area
let storage: [String: Storage] = [
"messages": .model(object: "InboxStore"),
"isShowingDetail": .state(owner: "InboxScreen"),
]
let screen = ViewTree(nodes: [
ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
ViewNode("MessageList", parent: "InboxScreen", reads: ["messages"]),
ViewNode("DetailBanner", parent: "InboxScreen", reads: []),
], storage: storage)
let recomputes = [
screen.change("message-deleted", key: "messages"),
screen.change("detail-opened", key: "isShowingDetail"),
].flatMap { $0 }
countCauses(recomputes)
message-deleted: messages changed | recomputed MessageList observed | skipped InboxScreen,DetailBanner detail-opened: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList,DetailBanner local: 1 parent: 0 observed: 1 environment: 0
The deleted message recomputes only MessageList, and the trigger is observed: neither the list itself nor its parent made the change; the object changed, and the list was reading the relevant property of that object. The screen is skipped, because it does not read messages. The change that opens the detail area recomputes the screen through the local trigger; the view that reads the condition and owns it is the screen. The list and the detail area appearing as skipped in the line says these two views were not computed in that change.
One point deserves attention: the entry of the detail area. The area entered the tree, but the model did not count this as a recomputation: DetailBanner is among the skipped. The first computation of a view is not the recomputation of an existing view, and the model measures only the second.
IP9. This measurement holds only for a tree with a single list that reads messages. A layout with several views reading the same object was not measured.
IP10. This measurement represents the deletion change with a single label. Which row of the list will go away, how the row identities are matched, and whether the transition is applied to the row were not measured.
Observation Tracking Reacts Only to the Property That Is Read
The rule of the observation channel is not “the object changed” but “the property that is read changed”. This rule is not an assumption of the model; the observation tracking module is compiled in the sandbox and the rule is measured directly. In the block below, InboxStore carries two properties; the tracking reads only messages. First the untracked unreadCount changes, then the tracked messages.
// main.swift — observation tracking sees only the property that is read
import Observation
@Observable
final class InboxStore {
var messages: [String] = ["report", "invoice"]
var unreadCount = 0
}
let store = InboxStore()
withObservationTracking {
_ = store.messages
} onChange: {
print("tracked property changed")
}
store.unreadCount += 1
print("unreadCount changed")
store.messages.removeFirst()
print("messages changed")
unreadCount changed tracked property changed messages changed
The untracked unreadCount did not trigger the callback: no callback message came after the first line. The tracked messages did. The callback ran inside the assignment, synchronously: its message was printed before the messages changed line that comes after the assignment. Whether the callback ran before or after the value was written was not measured in this block; the lesson does not claim it. This measurement also does not show how the framework attaches this tracking to the view body. We can say that the framework computes the body inside a tracking; the details of that attachment are a matter for reading the source.
These two measurements need to be read together. The observed trigger in the model rests on the behavior in the tracking measurement: because the list reads messages, it is recomputed when that property changes, and not recomputed when unreadCount changes. If in an interface the change of an unread property does not affect the view, the reason is that the view never uses that property in its body; holding the object as a whole is not enough. The approach that notifies at the object level does not make this distinction: with ObservableObject and @Published, every published change invalidates all the views observing the object. That approach depends on the reactive-streams framework and is not measured here; it is mentioned as source.
IP11. This measurement measures the tracking itself, not the view tree. That the tracking ends after a single change and has to be set up again for a second change was not shown in this block.
IP12. This measurement covers a single object with two properties. The numbers of the approach that notifies at the object level were not measured.
The Bounding Measurement
What happens to the departing view? When the detail area closes, DetailBanner leaves the tree; it goes through no recomputation, reads no new value, and is not computed. The model shows this as follows: the tree knows a view only as far as it contains it. We write the same change in two trees, one where the area is in the tree and one where it is not.
// main.swift — the departing view is not in the tree
let storage: [String: Storage] = [
"isShowingDetail": .state(owner: "InboxScreen"),
]
let before = ViewTree(nodes: [
ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
ViewNode("MessageList", parent: "InboxScreen", reads: []),
ViewNode("DetailBanner", parent: "InboxScreen", reads: []),
], storage: storage)
let after = ViewTree(nodes: [
ViewNode("InboxScreen", parent: nil, reads: ["isShowingDetail"]),
ViewNode("MessageList", parent: "InboxScreen", reads: []),
], storage: storage)
let closing = before.change("detail-closing", key: "isShowingDetail")
let closed = after.change("detail-closed", key: "isShowingDetail")
print(closing.count == closed.count)
detail-closing: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList,DetailBanner detail-closed: isShowingDetail changed | recomputed InboxScreen local | skipped MessageList true
The area that is closing appears in the skipped list in the first tree; in the second tree it does not appear at all. The view recomputed in both trees is the same: only InboxScreen. The departing view does not appear among the recomputed ones in any line. For this reason the model does not count the removal of a view as a separate event. When the exit transition ends, whether the departing view is computed during the exit, and when it is fully deleted from the tree were not measured.
Summary
- A transition shapes a view’s entry into and exit from the tree; for it to be seen, the change must be wrapped in an animation.
- An item deleted from the model recomputes the list that reads that model through the observed trigger.
- Observation tracking reacts only to the property that is read; the change of an unread property does not trigger the callback.
- The state that opens the detail area recomputes the screen that owns it through the local trigger.
- The departing view is not recomputed, it leaves the tree; the model does not treat this exit as a counted event.
- This lesson’s trigger: observed.
Next Step
So far the source of the values was either a view’s state or a model object. In the next lesson we will look at values whose source is the user’s system settings: environment values such as the Dynamic Type size and the Reduce Motion preference, and which views they recompute together with the accessibility features.
To keep your progress and take notes, Log in
My notes
Log in to take notes.