Mastering Animatable and AnimatablePair in SwiftUI
SwiftUI makes creating animations a breeze, but sometimes you need a bit more control over how things move and animate.
In this article, we'll explore Animatable
and AnimatablePair
and we'll see how we can use these APIs to craft more advanced animations in our apps. But, before we do that, let's make sure we understand the problem it solves.
In the following code, whenever the user taps on the Rectangle
, I want to animate the change in dimensions:
Hmm, do you see how the Rectangle
just snaps to its new dimension without any animation? I'm using withAnimation
and updating the width
and height
- what's going on?
When dealing with custom objects, such as a new Shape
with a custom Path
, SwiftUI doesn't know how to interpolate custom properties like width
and height
between their initial and final states.
To handle this, we need to use the Animatable
protocol to explicitly tell SwiftUI how to interpolate these properties.
Animatable
Luckily for us, all Shape
's in SwiftUI already conform to Animatable
:
/// A 2D shape that you can use when drawing a view.
///
/// Shapes without an explicit fill or stroke get a default fill based on the
/// foreground color.
///
/// You can define shapes in relation to an implicit frame of reference, such as
/// the natural size of the view that contains it. Alternatively, you can define
/// shapes in terms of absolute coordinates.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol Shape : Sendable, Animatable, View
/// A type that describes how to animate a property of a view.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol Animatable {
/// The type defining the data to animate.
associatedtype AnimatableData : VectorArithmetic
/// The data to animate.
var animatableData: Self.AnimatableData { get set }
}
If you are trying to synchronize animation between properties on other types, don't forget to make the type conform to Animatable
.
So, it would appear that all we need to do is tweak the implementation of animatableData
.
Ultimately, I want to animate the width
and height
together, but I can only return one value (i.e. var animatableData: Double
). So, let's see what happens when I modify just the width
:
var animatableData: Double {
get { width }
set { width = newValue}
}
With this addition, we finally have animation, but you'll notice that the change to the height
is applied immediately and then the width
is animated. Progress, I guess?
We're heading in the right direction, but since Animatable
will only allow us to return one value - either width
or height
- we'll have to use another solution to animate these properties in sync.
AnimatablePair
If we want to synchronize the animation of the multiple properties together, we need to use AnimationPair
instead:
/// A pair of animatable values, which is itself animatable.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct AnimatablePair<First, Second> : VectorArithmetic where First : VectorArithmetic, Second : VectorArithmetic
So, let's change our Animatable
conformance to return an AnimatablePair
instead of a Double
:
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(width, height)
}
set {
width = newValue.first
height = newValue.second
}
}
Great! The width
and height
are finally animating together!
Now, that we have a way of synchronizing the animation of 2 properties, we can start to build some really cool animations.
If you find yourself needing to synchronize more than 2 properties, you can extend AnimatablePair
like this:
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat> {
get {
AnimatablePair(AnimatablePair(width, height), labelScale)
}
set {
width = newValue.first.first
height = newValue.first.second
someOtherProperty = newValue.second
}
}
Morphing Shapes
Let's say you want to animate a Shape
that morphs between a circle and a rounded rectangle. We can use AnimatablePair
to help animate the cornerRadius
and size
simultaneously.
struct MorphingShape: Shape {
var size: CGFloat
var cornerRadius: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(size, cornerRadius)
}
set {
size = newValue.first
cornerRadius = newValue.second
}
}
func path(in rect: CGRect) -> Path {
let adjustedSize = min(size, rect.width, rect.height)
let rect = CGRect(
x: (rect.width - adjustedSize) / 2,
y: (rect.height - adjustedSize) / 2,
width: adjustedSize,
height: adjustedSize
)
return Path(roundedRect: rect, cornerRadius: cornerRadius)
}
}
struct ContentView: View {
@State private var size: CGFloat = 100
@State private var cornerRadius: CGFloat = 50
var body: some View {
MorphingShape(size: size, cornerRadius: cornerRadius)
.fill(Color.green)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation(
.spring(
response: 1.0,
dampingFraction: 0.5,
blendDuration: 1.0
)
) {
size = CGFloat.random(in: 50...150)
cornerRadius = CGFloat.random(in: 0...75)
}
}
}
}
Synchronizing Text
As we've already seen, there are several types of animations and transitions that do not have built-in interpolation mechanisms in SwiftUI and require the implementation of the Animatable
protocol:
- Custom Shapes: If you create custom shapes with properties that need to animate (e.g., path points), you need to conform to
Animatable
to provide smooth transitions. - Complex Property Combinations: When you have multiple properties that need to animate together, such as the position and size of a
Shape
, or the corner radius and shadow radius of aView
. - Non-Numeric Properties: Properties that are not inherently numeric, such as color components or certain
enum
values, require custom interpolation. - Non-Standard Animations: Any non-standard or complex animations that involve more than simple position, size, rotation, or opacity changes typically require
Animatable
.
This also extends to Text
, where SwiftUI can easily animate properties like opacity or font size but struggles with animating the actual text content.
In this example, we aim to animate changes to the Text
component's content.
Without using Animatable
or AnimatablePair
, SwiftUI defaults to a fade animation, which looks clunky:
Once we add Animatable
and AnimatablePair
, the animation looks much better, as SwiftUI can now use animatableData
to accurately interpolate between the starting and ending values:
struct AnimatableTextView: View, Animatable {
var value1: Double
var value2: Double
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(value1, value2)
}
set {
value1 = newValue.first
value2 = newValue.second
}
}
var body: some View {
VStack {
Text(String(format: "%.2f", value1))
.font(.largeTitle)
.foregroundColor(.red)
.padding()
Text(String(format: "%.2f", value2))
.font(.largeTitle)
.foregroundColor(.blue)
.padding()
}
}
}
struct ContentView: View {
@State private var value1: Double = 0.0
@State private var value2: Double = 0.0
@State private var animate = false
var body: some View {
VStack {
AnimatableTextView(value1: value1, value2: value2)
Button("Animate Values") {
withAnimation(.easeInOut(duration: 2)) {
value1 = animate ? 100.0 : 0.0
value2 = animate ? 200.0 : 0.0
}
animate.toggle()
}
}
.frame(width: 300, height: 200)
.padding()
}
}
If you're interested in more articles about iOS Development & Swift, check out my YouTube channel or follow me on Twitter.
And, if you're an indie iOS developer, make sure to check out my newsletter! Each issue features a new indie developer, so feel free to submit your iOS apps.