Lesson 17 / 21
Implicit and Explicit Animations
Measuring that a change wrapped in an animation recomputes the same views through the same trigger as the identical change left unwrapped.
Published Updated
In this lesson
- Separates implicit from explicit animation by who wraps the change
- Measures that a local change wrapped in an explicit animation recomputes only its owner
- Measures that the trigger is parent when an implicit animation follows a value that comes from the parent
- Shows that the model counts an animated and an unanimated change the same way
Contents
In the previous lesson we measured that when a drag ends the position changes and the card stays in its new place. Whether that change reaches the view abruptly or through intermediate values is a separate decision, and the tool that makes it is animation. The question is the same: what triggered this recomputation? The trigger examined in this lesson is local; a parent example sits beside it.
An animation is a declarative definition that spreads the reflection of a change onto the view over time. It is added in two ways. An explicit animation wraps the code that makes the change: whoever writes withAnimation { isExpanded.toggle() } has said “let this change be reflected with an animation.” An implicit animation is attached to the view: the .animation(_:value:) modifier applies an animation to the changes beneath that view when the given value changes. The first is written where the change is made, the second in the view that sees the change. Neither changes a value; each only selects how the change is reflected.
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)")
}
}
One Change, Two Wrappings
The fragment below has a panel. The panel’s detail area opens and closes with its own state, isExpanded, and the button wraps this change in an explicit animation. The panel’s opacity depends on the isOn value given by the screen, and an implicit animation follows this value.
import SwiftUI
struct PanelScreen: View {
@State private var isOn = false
var body: some View {
VStack {
Toggle("Enabled", isOn: $isOn)
DetailPanel(isOn: isOn)
StatusText()
}
}
}
struct DetailPanel: View {
let isOn: Bool
@State private var isExpanded = false
var body: some View {
VStack {
Button("Details") {
withAnimation {
isExpanded.toggle()
}
}
if isExpanded {
Text("Detail text")
}
}
.opacity(isOn ? 1 : 0.4)
.animation(.default, value: isOn)
}
}
struct StatusText: View {
var body: some View {
Text("Ready")
}
}
This fragment does not compile and produces no output; it stands as source only. The reading layout is as follows. PanelScreen owns isOn and uses it in its body both as a binding and as a value; that is, it reads the value. DetailPanel takes isOn as a parameter and reads it for its opacity, and reads isExpanded as its own state. StatusText reads neither. We rebuild this reading layout with plain values. The model builds the framework’s dependency-tracking rule; it is not the framework itself.
We write two changes: the panel opening with an explicit animation, and the isOn value on the screen changing. In the second, the implicit animation is in effect.
// main.swift — a local change wrapped in an explicit animation and a parent value followed by an implicit one
let storage: [String: Storage] = [
"isOn": .state(owner: "PanelScreen"),
"isExpanded": .state(owner: "DetailPanel"),
]
let screen = ViewTree(nodes: [
ViewNode("PanelScreen", parent: nil, reads: ["isOn"]),
ViewNode("DetailPanel", parent: "PanelScreen", reads: ["isOn", "isExpanded"]),
ViewNode("StatusText", parent: "PanelScreen", reads: []),
], storage: storage)
let recomputes = [
screen.change("panel-opened", key: "isExpanded"),
screen.change("enabled-changed", key: "isOn"),
].flatMap { $0 }
countCauses(recomputes)
panel-opened: isExpanded changed | recomputed DetailPanel local | skipped PanelScreen,StatusText enabled-changed: isOn changed | recomputed PanelScreen local, DetailPanel parent | skipped StatusText local: 2 parent: 1 observed: 0 environment: 0
The first line is the explicit animation’s measurement. The change inside withAnimation recomputes DetailPanel, the owner of isExpanded; the trigger is local. The screen and the status text are skipped. The animation did not change this count, because the wrapper does not determine which view reads; it only says how the reflection will happen.
The second line is the implicit animation’s measurement. When isOn changes, its owner PanelScreen is recomputed through the local trigger, and DetailPanel through the parent trigger, because it takes the value from its parent. The view that carries the implicit animation is not the view that makes the change: DetailPanel carries the modifier, while the value is on the screen. StatusText is skipped again. The final count gives two local and one parent recomputation; observed and environment are zero.
IP5. This measurement holds only for a tree in which isExpanded is DetailPanel‘s own state and isOn is the screen’s state. A layout in which these two values belong to the same owner was not measured.
IP6. This measurement assumes that the body of DetailPanel reads isOn. A way of writing in which the value the modifier follows is not read in the body was not measured.
An Animation Is Not a Trigger
These two lines show the distinction at the center of the lesson. An explicit animation wraps a change, an implicit animation follows a value; neither produces a trigger. The trigger is still the value’s own storage path: local state, a value from the parent, an observed object or an environment value. The animation adds no fifth channel to any of these four.
The practical consequence of this distinction shows up when debugging. If a view is seen being recomputed unexpectedly, one looks at which value changed before blaming the animation. Removing the animation does not remove the recomputation; it removes only the intermediate values. On the other hand, it matters which value an implicit animation is bound to: the value: parameter selects which change will be reflected with an animation. The form without a value parameter applies the animation to every change beneath the view and has been deprecated; for this reason the animation is written bound to a value with the value: parameter.
The advantage of the explicit animation is its scope. Changes made inside the withAnimation block are reflected with that animation; a change made outside the block does not receive this animation and, if there is no other animation source, is reflected without one. If the same value is written from two different places, one may be animated and the other not. The model does not separate these two writes.
In a code review this distinction turns into three questions. The first is whose the value is: is the view that holds the value the same as the view that changes it? If so, the trigger is local, and the animation, explicit or implicit, concerns only that view. The second is who reads the value: views that receive it from the parent as a parameter or a binding are recomputed through the parent trigger, and the view that carries the implicit animation is often one of them. The third is where the animation is written: in the code that makes the change, or in the view that follows the value? This third question does not change the measurement result, but it makes it easier for the reader to find the intent. A withAnimation block says the change is animated on purpose; an .animation(_:value:) modifier says the view should always react to this value with an animation.
In the example, StatusText was skipped on both lines. This does not mean that every view of an animated screen moves; only the view that reads the value is recomputed. A view that does not read stays as it is, unaffected by its sibling’s animation.
IP7. This measurement shows, for only two changes, that an animation does not produce a trigger. Whether the framework does extra computation when the animation ends was not measured.
IP8. This measurement says nothing about duration, curve and frame rate. These three values were neither printed nor compared.
The Bounding Measurement
We write the same key twice; in one the change is wrapped in an animation, in the other it is not. The model carries no field that could set up this difference. This is a deliberate limit: the core’s change function takes only the key and the label.
// main.swift — the same change with and without animation
let storage: [String: Storage] = [
"isExpanded": .state(owner: "DetailPanel"),
]
let screen = ViewTree(nodes: [
ViewNode("DetailPanel", parent: nil, reads: ["isExpanded"]),
ViewNode("StatusText", parent: nil, reads: []),
], storage: storage)
let animated = screen.change("animated", key: "isExpanded")
let plain = screen.change("unanimated", key: "isExpanded")
print(animated.map(\.view) == plain.map(\.view))
print(animated.map(\.cause) == plain.map(\.cause))
animated: isExpanded changed | recomputed DetailPanel local | skipped StatusText unanimated: isExpanded changed | recomputed DetailPanel local | skipped StatusText true true
The two changes give the same line, and both comparisons are true: the same view, the same trigger. The model cannot count animation; it does not need to, because this course’s measure is the reason for a recomputation, not the view’s intermediate values. How the animation looks on screen, how long it lasts, and whether the body is called again during the intermediate frames were not measured.
Summary
- An explicit animation wraps the change, an implicit animation wraps the view; neither changes the value, each selects the form of the reflection.
- A local change wrapped in an explicit animation recomputes the value’s owner through the local trigger.
- If the value an implicit animation follows comes from the parent, that view is recomputed through the parent trigger.
- The same change, animated and unanimated, counts the same views through the same trigger in the model.
- This lesson’s trigger: local.
Next Step
In this lesson the change always recomputed a view; the view stayed in the tree. In the next lesson we will measure a view being added to the tree and removed from it, that is, the transition. There we will look at which view an item deleted from a model recomputes, and why the view that goes away is not counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.