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.