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:
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:
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:
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:
// 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:
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:
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
:
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.