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:

0:00
/0:03

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}
}
0:00
/0:07

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
    }
}
0:00
/0:09

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)
                }
            }
    }
}
0:00
/0:08

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 a View.
  • 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:

0:00
/0:03

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:

0:00
/0:03
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.

Ace The iOS Interview
The best investment for landing your dream iOS jobHey there! My name is Aryaman Sharda and I started making iOS apps way back in 2015. Since then, I’ve worked for a variety of companies like Porsche, Turo, and Scoop Technologies just to name a few. Over the years, I’ve mentored junior engineers, bui…
Indie Watch
Indie Watch is an exclusive weekly hand-curated newsletter showcasing the best iOS, macOS, watchOS, and tvOS apps from developers worldwide.
Universal Link & Apple App Site Association Testing Tool
Easily verify and test Universal GetUniversal.link is a free tool for verifying and testing Apple App Site Association (AASA) files. Ensure your Universal Links are configured correctly with easy link creation, real-time testing, and team collaboration features. Save the website as a bookmark for quick access on devices and simulators. Simplify your AASA file troubleshooting today!