Animatable Protocol: Taming Unruly SwiftUI Animations

Published on

In SwiftUI development, have you ever encountered situations where seemingly correct animation code fails to work as expected? Or animations that run perfectly on certain iOS versions but behave abnormally on others? These frustrating animation issues can often be resolved with a powerful yet understated tool — the Animatable protocol.

The Animatable Protocol

Before diving into how to solve animation anomalies, let’s first understand the core mechanism of the Animatable protocol. The most notable feature of this protocol is that it elevates the way view animations are handled from a simple “start-to-end” state-driven approach to a more nuanced “frame-by-frame interpolation” driven approach.

Normal State-Driven Approach

Let’s start with a basic animation example — a state-driven horizontal movement effect:

Swift
struct OffsetView: View {
  @State var x: CGFloat = 0
  var body: some View {
    Button("Move") {
      x = x == 0 ? 200 : 0
    }
    Rectangle()
      .foregroundStyle(.red)
      .frame(width:100, height: 100)
      .offset(x: x)
      .animation(.smooth, value: x)
  }
}

Animatable-Based Implementation

The same effect can also be achieved by implementing the Animatable protocol:

Swift
struct OffsetView: View {
  @State var x: CGFloat = 0
  var body: some View {
    Button("Move") {
      x = x == 0 ? 200 : 0
    }
    MoveView(x: x)
      .animation(.smooth, value: x)
  }
}

struct MoveView: View, Animatable {
  var x: CGFloat
  // Receive animation interpolation via animatableData
  var animatableData: CGFloat {
    get { x }
    set { x = newValue }
  }
  
  var body: some View {
    Rectangle()
      .foregroundStyle(.red)
      .frame(width: 100, height: 100)
      .offset(x: x)
  }
}

At first glance, the Animatable-based implementation might seem like overkill. Indeed, in most standard animation scenarios, we can rely on SwiftUI’s state-driven mechanism to create smooth animations. This is why, in everyday development, you rarely need to directly interact with the Animatable protocol.

Want to dive deeper into how Animatable works? Check out Demystifying SwiftUI Animation: A Comprehensive Guide.

Using Animatable to Solve Animation Anomalies

While SwiftUI’s animation system is powerful, there are times when even seemingly correct code can result in unexpected animation anomalies. In such cases, Animatable often comes to the rescue.

Interestingly, while writing this article, I discovered that many animation issues that previously required Animatable to fix have been resolved in Xcode 16. To better illustrate the problem, I borrowed a typical case from the Apple Developer Forums.

Problem Demonstration

Let’s look at an example using the new animation modifier introduced in iOS 17:

Swift
struct AnimationBugDemo: View {
  @State private var animate = false
  var body: some View {
    VStack {
      Text("Hello, world!")
        .animation(.default) {
          $0
            .opacity(animate ? 1 : 0.2)
            .offset(y: animate ? 0 : 100) // <-- Animation anomaly
        }
      Button("Change") {
        animate.toggle()
      }
    }
  }
}

This code looks perfectly normal — we’re using the new version of the animation modifier to precisely control the animation scope. However, upon running it, you’ll notice that while the opacity change works fine, the offset animation is completely missing.

Animatable Solution

Upon analysis, the issue lies in the offset modifier not correctly handling the animation state within the animation closure. Let’s use Animatable to implement a reliable alternative:

Swift
// Code from kurtlee93
public extension View {
    func projectionOffset(x: CGFloat = 0, y: CGFloat = 0) -> some View {
        self.projectionOffset(.init(x: x, y: y))
    }
    func projectionOffset(_ translation: CGPoint) -> some View {
        modifier(ProjectionOffsetEffect(translation: translation))
    }
}

private struct ProjectionOffsetEffect: GeometryEffect {
    var translation: CGPoint
    var animatableData: CGPoint.AnimatableData {
        get { translation.animatableData }
        set { translation = .init(x: newValue.first, y: newValue.second) }
    }
    public func effectValue(size: CGSize) -> ProjectionTransform {
        .init(CGAffineTransform(translationX: translation.x, y: translation.y))
    }
}

Now, simply replace the original offset with our custom modifier:

Swift
Text("Hello, world!")
    .animation(.default) {
        $0
           .opacity(animate ? 1 : 0.2)
           .projectionOffset(y: animate ? 0 : 100)
     }

Why Choose Animatable?

Although this issue could also be resolved by using explicit animations or reverting to the old version of the animation modifier, the Animatable-based solution offers distinct advantages:

  • Maintains the precise control capabilities of the new animation modifier
  • Avoids potential side effects from using withAnimation, such as triggering animations in unrelated views

In other words, this solution not only fixes the current issue but also provides us with more granular control over animations.

Using Animatable to Create More Precise Animations

In a previous article, SwiftUI geometryGroup() Guide: From Theory to Practice, I discussed how to use the geometryGroup modifier to improve animation effects. This modifier works similarly to Animatable — both convert discrete states into continuous animation data streams. Today, let’s explore how to use Animatable to further enhance animation experiences.

Special thanks to @Chocoford for providing the sample code. The full implementation can be viewed here.

View Expansion Anomaly

Consider this example of an expanding menu:

Swift
ZStack {
  if isExpanded {
    ItemsView(namespace: namespace)
  } else {
    Image(systemName: "sun.min")
      .matchedGeometryEffect(id: "Sun2", in: namespace, properties: .frame, isSource: false)
  }
}

When the menu view is dragged to the center of the screen to expand, the animation effect is completely lost due to the lack of original position information. Although adding geometryGroup can make the animation reappear:

However, the keen-eyed among you might have noticed: the menu’s expansion direction is unnatural — it expands from left to right, rather than the expected center-outward expansion. This indicates that while geometryGroup achieves animation interpolation, its behavior is difficult to control precisely.

Animatable Optimization Solution

Let’s redesign this animation using Animatable:

Swift
struct AnimatableContainerSizeModifier: Animatable, ViewModifier {
  var targetSize: CGSize
  var animatableData: AnimatablePair<CGFloat, CGFloat> {
    get { AnimatablePair(targetSize.width, targetSize.height) }
    set { targetSize = CGSize(width: newValue.first, height: newValue.second) }
  }
  func body(content: Content) -> some View {
    content.frame(width: targetSize.width, height: targetSize.height)
  }
}

// Apply the new animation controller
FloatingToolbar(isExpanded: isExpanded)
    .modifier(AnimatableContainerSizeModifier(targetSize: CGSize(width: isExpanded ? 300 : 100, height: 100)))

The effect is immediate:

This new solution not only makes the menu expansion animation more natural but also provides a smoother user experience.

Conclusion

Although the Animatable protocol was not originally designed to fix animation issues, it has become a powerful tool for handling tricky animation problems. When you encounter:

  • Seemingly correct code producing abnormal animations
  • Animations behaving inconsistently across different system versions
  • The need for more precise animation control

Consider using Animatable — it might just be the key to unlocking the right animation.

Weekly Swift & SwiftUI highlights!