When .animation animates more (or less) than it’s supposed to

On the positioning of the .animation modifier in the view tree, or: “Rendering” vs. “non-rendering” view modifiers

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