This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We reimplement SwiftUI's new keyframe-based animations, starting with linear keyframes.

00:06 Today we'll start a new series in which we reimplement SwiftUI's new keyframe animation API. We already know the type of animation where we transition a view tree from state A to state B, and we control how this animates by writing custom animatable modifiers. However, keyframe animations work differently. Rather than being direct animations of the view tree, we can think of them as interpolations of values over time. These interpolated values can be used to move things around onscreen, or for anything we want really.

01:03 Keyframes are a concept from the history of animation. If we understand correctly, an illustrator would first draw a frame of a cartoon, and then another frame of where the cartoon should move. The frames in between the two keyframes would be drawn by other illustrators. This is analogous to the keyframes we define for an animation today: we say a value starts out as zero, and one second later we want the value to be one. Then we specify how the value should be interpolated between start and finish: linearly, or following some type of curve.

01:48 As we saw with the Observation framework over the past few episodes, a good way of getting familiar with new APIs is to try and build them ourselves. So that's what we're going to do.

Keyframes

02:11 To give the new animation API a spin, when a button is pressed, it'll shake. We first create a state property that can be used as a trigger for the animation:

import SwiftUI

struct ContentView: View {
    @State private var shakes = 0

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            Button("Shake") {
                shakes += 1
            }
        }
        .padding()
    }
}

02:30 Whenever the shakes value changes, we want to do a keyframe animation. We call the keyframeAnimator modifier on the button, passing in both the shakes trigger and a closure that modifies the view as the animation plays:

struct ContentView: View {
    @State private var shakes = 0

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            Button("Shake") {
                shakes += 1
            }
            .keyframeAnimator(initialValue: 0, trigger: shakes, content: { view, value in
                view
                    .offset(x: value)
            }, keyframes: { _ in
                
            })
        }
        .padding()
    }
}

03:16 Using the special builder syntax of the keyframes parameter, we create keyframes to animate the button's offset from 0 to -30, then to 30, and then back to 0:

struct ContentView: View {
    @State private var shakes = 0

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            Button("Shake") {
                shakes += 1
            }
            .keyframeAnimator(initialValue: 0, trigger: shakes, content: { view, value in
                view
                    .offset(x: value)
            }, keyframes: { _ in
                LinearKeyframe(-30, duration: 0.5)
                LinearKeyframe(30, duration: 1)
                LinearKeyframe(0, duration: 0.5)
            })
        }
        .padding()
    }
}

04:00 To try this out, we need to mark the deployment target as iOS 17 and run our app in the simulator. When we tap the button, it moves to the left, then to the right, then back to the center. The second time we tap, the animation is a bit wonky, and the third time it doesn't work at all. But these are known bugs in the beta software — the code we wrote should just work.

04:38 We can replace the linear keyframes with cubic keyframes. This creates a smoother animation curve, so instead of moving at a constant speed, the button slowly ramps up, and then it slows back down toward the end, creating a more natural motion:

CubicKeyframe(-30, duration: 0.5)
CubicKeyframe(30, duration: 1)
CubicKeyframe(0, duration: 0.5)

05:14 Instead of just animating a single CGFloat and using it for the button's offset, we can also animate multiple properties at once with keyframe tracks.

Keyframe Tracks

05:33 We first write a struct with two properties. We'll animate these properties and use them for the offset and rotation of the button:

struct ShakeData {
    var offset: CGFloat = 0
    var rotation: Angle = .zero
}

06:06 We pass a ShakeData into the animator as the initial value, and we update the content closure to apply both the offset and rotationEffect modifiers to the button:

Button("Shake") {
    shakes += 1
}
.keyframeAnimator(initialValue: ShakeData(), trigger: shakes, content: { view, value in
    view
        .offset(x: value.offset)
        .rotationEffect(value.rotation)
}, keyframes: { _ in
    // ...
})

06:36 Now we can independently animate the two properties of ShakeData by creating a keyframe track for each one:

Button("Shake") {
    shakes += 1
}
.keyframeAnimator(initialValue: ShakeData(), trigger: shakes, content: { view, value in
    view
        .offset(x: value.offset)
        .rotationEffect(value.rotation)
}, keyframes: { _ in
    KeyframeTrack(\.offset) {
        CubicKeyframe(-30, duration: 0.5)
        CubicKeyframe(30, duration: 1)
        CubicKeyframe(0, duration: 0.5)
    }
    KeyframeTrack(\.rotation) {
        CubicKeyframe(.degrees(30), duration: 0.5)
        CubicKeyframe(.degrees(-30), duration: 1)
        CubicKeyframe(.zero, duration: 0.5)
    }
})

08:06 The two keyframe tracks run in parallel, so they don't need to have the same keyframes or the same duration. For example, we could change the timing of the rotation track to go from 0 to 30 in one second, and then back to 0 in the next second:

Button("Shake") {
    shakes += 1
}
.keyframeAnimator(initialValue: ShakeData(), trigger: shakes, content: { view, value in
    view
        .offset(x: value.offset)
        .rotationEffect(value.rotation)
}, keyframes: { _ in
    KeyframeTrack(\.offset) {
        CubicKeyframe(-30, duration: 0.5)
        CubicKeyframe(30, duration: 1)
        CubicKeyframe(0, duration: 0.5)
    }
    KeyframeTrack(\.rotation) {
        CubicKeyframe(.degrees(30), duration: 1)
        CubicKeyframe(.zero, duration: 1)
    }
})

08:50 This sample animation looks a bit funny, but we're just trying to demonstrate how the API works. And now that we know, we can start to lay out some of the types we need to implement it ourselves.

Implementing Linear Keyframes

09:06 We've now seen the view-based API. But there's also a KeyframeTimeline struct where we pass in an initial value and the keyframes, and we can ask it for the value at a specific time. If we want to build our own keyframe animator, we'll need to reimplement this timeline struct, as well as keyframes and keyframe tracks.

09:39 And maybe we can start with a test of a linear keyframe:

final class KeyframeReimplementationTests: XCTestCase {
    func testKeyframe() throws {
        var x = MyLinearKeyframe(to: 100 as CGFloat, duration: 2)
        
    }
}

10:09 We then create the MyLinearKeyframe struct. This is generic over the Value type it interpolates, and to interpolate between values, the Value parameter needs to be Animatable:

struct MyLinearKeyframe<Value: Animatable> {
    var to: Value
    var duration: TimeInterval

    func interpolate(from: Value, time: TimeInterval) -> Value {
        let progress = time/duration
        var result = from
        result.animatableData.interpolate(towards: to.animatableData, amount: progress)
        return result
    }
}

12:11 Back in our test, we can now write some assertions about values returned by the keyframe. After a half second, a quarter of the timeline has passed, so the keyframe should return 25. And at one second, the value should be 50:

final class KeyframeReimplementationTests: XCTestCase {
    func testKeyframe() throws {
        var x = MyLinearKeyframe(to: 100 as CGFloat, duration: 2)
        XCTAssertEqual(x.interpolate(from: 0, time: 0.5), 25)
        XCTAssertEqual(x.interpolate(from: 0, time: 1), 50)
        XCTAssertEqual(x.interpolate(from: 0, time: 2), 100)
    }
}

13:09 We won't test what the keyframe should return for a time interval longer than its duration, e.g. after 2.5 seconds, because this is better handled by the timeline or by the keyframe track. That way, the keyframe itself only has to know about interpolating a value from start to finish.

Implementing Keyframe Tracks

13:41 The next step is building the keyframe track. This is a struct that stores a key path and an array of keyframes. To store a key path, we need to add generic parameters for the key path's root and value types. In our example, the root type is ShakeData, and the value type is either CGFloat or Angle:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]
}

15:35 The compiler tells us that Value needs to be Animatable for it to be used as the generic parameter of the MyLinearKeyframe type. We'll replace this concrete keyframe type with a protocol later on, and then we can drop that constraint, but until then, we need it.

15:58 The keyframe track also needs a method to request a value at a specific time:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]

    func value(at time: TimeInterval) -> Value {

    }
}

16:23 This implementation isn't so straightforward, because we need to find out which keyframe is responsible for the time we're asking for, so let's first write a test to describe how this method should work.

16:42 We construct a keyframe track that operates on a simple CGFloat as the root type, so we pass in \.self as the key path. The keyframes are passed in as an array because we won't have write a result builder for keyframes:

final class KeyframeReimplementationTests: XCTestCase {
    // ...

    func testTrack() throws {
        let track = MyKeyframeTrack(keyPath: \.self, keyframes: [
            MyLinearKeyframe(to: 100, duration: 1),
            MyLinearKeyframe(to: 150, duration: 1)
        ])

    }
}

17:43 To interpolate between values, the keyframe track also needs to receive an initial value, so we tweak the signature of the value method:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]

    func value(at time: TimeInterval, initialValue: Value) -> Value {

    }
}

18:33 Now we can write assertions about the values expected at certain time intervals during the duration of the animation. We also assert that the track returns the last keyframe's value for a time interval beyond the duration of the track:

final class KeyframeReimplementationTests: XCTestCase {
    // ...

    func testTrack() throws {
        let track = MyKeyframeTrack(keyPath: \.self, keyframes: [
            MyLinearKeyframe(to: 100, duration: 1),
            MyLinearKeyframe(to: 150, duration: 1)
        ])
        XCTAssertEqual(track.value(at: 0.5, initialValue: 0), 50)
        XCTAssertEqual(track.value(at: 1.5, initialValue: 0), 125)
        XCTAssertEqual(track.value(at: 3, initialValue: 0), 150)
    }
}

19:13 Let's work on the implementation to make this test pass. We'll need to iterate over the keyframes to find the keyframe whose time interval contains the time we're interested in. We only assign a duration to each keyframe, but since they form a sequence in the keyframe track, each keyframe implicitly has a time interval. The first keyframe's time interval goes from zero to its duration, the start of the second keyframe's time interval is the end of the first keyframe, and so on. When we find the correct keyframe, we can ask it for the interpolated value at the relative time within its own time interval.

20:11 Writing this out in code, we write a currentTime variable to keep track of the start time of the keyframe we're currently looking at, starting at 0 seconds. Looping over each keyframe, we compute the relative time by subtracting the current time from the requested time, and we check if this relative time lies within the keyframe's duration. At the end of each loop, we add the keyframe's duration to currentTime:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]

    func value(at time: TimeInterval, initialValue: Value) -> Value {
        var currentTime: TimeInterval = 0

        for keyframe in keyframes {
            let relativeTime = time - currentTime
            defer { currentTime += keyframe.duration }
            guard relativeTime <= keyframe.duration else {
                continue
            }

        }

    }
}

21:30 After the guard, we know that we're looking at the right keyframe and we have the relative time. But we also need to know the value from which to interpolate, i.e. the to value of the previous keyframe:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]

    func value(at time: TimeInterval, initialValue: Value) -> Value {
        var currentTime: TimeInterval = 0
        var previousValue = initialValue
        for keyframe in keyframes {
            let relativeTime = time - currentTime
            defer { currentTime += keyframe.duration }
            guard relativeTime <= keyframe.duration else {
                previousValue = keyframe.to
                continue
            }

            return keyframe.interpolate(from: previousValue, time: relativeTime)
        }

    }
}

22:39 In the end, this algorithm could be written more efficiently. The track could build up a dictionary of the keyframes' times, so that it can perform faster lookups. But the goal of this episode isn't to make our code super efficient.

22:57 If we haven't returned a value after the for loop, it means the requested time is greater than the duration of the track. In that case, we return the last keyframe's values, or, if we don't have any keyframes, the initial value:

struct MyKeyframeTrack<Root, Value: Animatable> {
    var keyPath: KeyPath<Root, Value>
    var keyframes: [MyLinearKeyframe<Value>]

    func value(at time: TimeInterval, initialValue: Value) -> Value {
        var currentTime: TimeInterval = 0
        var previousValue = initialValue
        for keyframe in keyframes {
            let relativeTime = time - currentTime
            defer { currentTime += keyframe.duration }
            guard relativeTime <= keyframe.duration else {
                previousValue = keyframe.to
                continue
            }

            return keyframe.interpolate(from: previousValue, time: relativeTime)
        }
        return keyframes.last?.to ?? initialValue
    }
}

23:11 Nice — our tests are now passing. Now that we have keyframes and tracks, the only thing we need is the keyframe timeline, which can combine multiple tracks. Let's see how we can construct that timeline next time.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

148 Episodes · 51h56min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes