The documentation for SwiftUI’s animation
modifier says:
Applies the given animation to this view when the specified value changes.
This sounds unambiguous to me: it sets the animation for “this view”, i.e. the part of the view tree that .animation
is being applied to. This should give us complete control over which modifiers we want to animate, right? Unfortunately, it’s not that simple: it’s easy to run into situations where a view change inside an animated subtree doesn’t get animated, or vice versa.
Unsurprising examples
Let me give you some examples, starting with those that do work as documented. I tested all examples on iOS 16.1 and macOS 13.0.
1. Sibling views can have different animations
Independent subtrees of the view tree can be animated independently. In this example we have three sibling views, two of which are animated with different durations, and one that isn’t animated at all:
struct Example1: View {
var flag: Bool
var body: some View {
HStack(spacing: 40) {
Rectangle()
.frame(width: 80, height: 80)
.foregroundColor(.green)
.scaleEffect(flag ? 1 : 1.5)
.animation(.easeOut(duration: 0.5), value: flag)
Rectangle()
.frame(width: 80, height: 80)
.foregroundColor(flag ? .yellow : .red)
.rotationEffect(flag ? .zero : .degrees(45))
.animation(.easeOut(duration: 2.0), value: flag)
Rectangle()
.frame(width: 80, height: 80)
.foregroundColor(flag ? .pink : .mint)
}
}
}
The two animation
modifiers each apply to their own subtree. They don’t interfere with each other and have no effect on the rest of the view hierarchy:
2. Nested animation
modifiers
When two animation
modifiers are nested in a single view tree such that one is an indirect parent of the other, the inner modifier can override the outer animation for its subviews. The outer animation applies to view modifiers that are placed between the two animation
modifiers.
In this example we have one rectangle view with animated scale and rotation effects. The outer animation applies to the entire subtree, including both effects. The inner animation
modifier overrides the outer animation only for what’s nested below it in the view tree, i.e. the scale effect:
struct Example2: View {
var flag: Bool
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.foregroundColor(.green)
.scaleEffect(flag ? 1 : 1.5)
.animation(.default, value: flag) // inner
.rotationEffect(flag ? .zero : .degrees(45))
.animation(.default.speed(0.3), value: flag) // outer
}
}
As a result, the scale and rotation changes animate at different speeds:
Note that we could also pass .animation(nil, value: flag)
to selectively disable animations for a subtree, overriding a non-nil
animation further up the view tree.
3. animation
only animates its children (with exceptions)
As a general rule, the animation
modifier only applies to its subviews. In other words, views and modifiers that are direct or indirect parents of an animation
modifier should not be animated. As we’ll see below, it doesn’t always work like that, but here’s an example where it does. This is a slight variation of the previous code snippet where I removed the outer animation
modifier (and changed the color for good measure):
struct Example3: View {
var flag: Bool
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.foregroundColor(.orange)
.scaleEffect(flag ? 1 : 1.5)
.animation(.default, value: flag)
// Don't animate the rotation
.rotationEffect(flag ? .zero : .degrees(45))
}
}
Recall that the order in which view modifiers are written in code is inverted with respect to the actual view tree hierarchy. Each view modifier is a new view that wraps the view it’s being applied to. So in our example, the scale effect is the child of the animation
modifier, whereas the rotation effect is its parent. Accordingly, only the scale change gets animated:
Surprising examples
Now it’s time for the “fun” part. It turns out not all view modifiers behave as intuitively as scaleEffect
and rotationEffect
when combined with the animation
modifier.
4. Some modifiers don’t respect the rules
In this example we’re changing the color, size, and alignment of the rectangle. Only the size change should be animated, which is why we’ve placed the alignment and color mutations outside the animation
modifier:
struct Example4: View {
var flag: Bool
var body: some View {
let size: CGFloat = flag ? 80 : 120
Rectangle()
.frame(width: size, height: size)
.animation(.default, value: flag)
.frame(maxWidth: .infinity, alignment: flag ? .leading : .trailing)
.foregroundColor(flag ? .pink : .indigo)
}
}
Unfortunately, this doesn’t work as intended, as all three changes are animated:
It behaves as if the animation
modifier were the outermost element of this view subtree.
5. padding
and border
This one’s sort of the inverse of the previous example because a change we want to animate doesn’t get animated. The padding
is a child of the animation
modifier, so I’d expect changes to it to be animated, i.e. the border should grow and shrink smoothly:
struct Example5: View {
var flag: Bool
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.padding(flag ? 20 : 40)
.animation(.default, value: flag)
.border(.primary)
.foregroundColor(.cyan)
}
}
But that’s not what happens:
6. Font modifiers
Font modifiers also behave seemingly erratic with respect to the animation
modifier. In this example, we want to animate the font width, but not the size or weight (smooth text animation is a new feature in iOS 16):
struct Example6: View {
var flag: Bool
var body: some View {
Text("Hello!")
.fontWidth(flag ? .condensed : .expanded)
.animation(.default, value: flag)
.font(.system(
size: flag ? 40 : 60,
weight: flag ? .regular : .heavy)
)
}
}
You guessed it, this doesn’t work as intended. Instead, all text properties animate smoothly:
Why does it work like this?
In summary, the placement of the animation
modifier in the view tree allows some control over which changes get animated, but it isn’t perfect. Some modifiers, such as scaleEffect
and rotationEffect
, behave as expected, whereas others (frame
, padding
, foregroundColor
, font
) are less controllable.
I don’t fully understand the rules, but the important factor seems to be if a view modifier actually “renders” something or not. For instance, foregroundColor
just writes a color into the environment; the modifier itself doesn’t draw anything. I suppose this is why its position with respect to animation
is irrelevant:
RoundedRectangle(cornerRadius: flag ? 0 : 40)
.animation(.default, value: flag)
// Color change still animates, even though we’re outside .animation
.foregroundColor(flag ? .pink : .indigo)
The rendering presumably takes place on the level of the RoundedRectangle
, which reads the color from the environment. At this point the animation
modifier is active, so SwiftUI will animate all changes that affect how the rectangle is rendered, regardless of where in the view tree they’re coming from.
The same explanation makes intuitive sense for the font modifiers in example 6. The actual rendering, and therefore the animation, occurs on the level of the Text
view. The various font modifiers affect how the text is drawn, but they don’t render anything themselves.
Similarly, padding
and frame
(including the frame’s alignment) are “non-rendering” modifiers too. They don’t use the environment, but they influence the layout algorithm, which ultimately affects the size and position of one or more “rendering” views, such as the rectangle in example 4. That rectangle sees a combined change in its geometry, but it can’t tell where the change came from, so it’ll animate the full geometry change.
In example 5, the “rendering” view that’s affected by the padding change is the border
(which is implemented as a stroked rectangle in an overlay). Since the border is a parent of the animation
modifier, its geometry change is not animated.
In contrast to frame
and padding
, scaleEffect
and rotationEffect
are “rendering” modifiers. They apparently perform the animations themselves.
Conclusion
SwiftUI views and view modifiers can be divided into “rendering“ and “non-rendering” groups (I wish I had better terms for these). In iOS 16/macOS 13, the placement of the animation
modifier with respect to non-rendering modifiers is irrelevant for deciding if a change gets animated or not.
Non-rendering modifiers include (non-exhaustive list):
- Layout modifiers (
frame
,padding
,position
,offset
) - Font modifiers (
font
,bold
,italic
,fontWeight
,fontWidth
) - Other modifiers that write data into the environment, e.g.
foregroundColor
,foregroundStyle
,symbolRenderingMode
,symbolVariant
Rendering modifiers include (non-exhaustive list):
clipShape
,cornerRadius
- Geometry effects, e.g.
scaleEffect
,rotationEffect
,projectionEffect
- Graphical effects, e.g.
blur
,brightness
,hueRotation
,opacity
,saturation
,shadow