SwiftUI Field Guide

Merging Animations

When the iPhone was introduced, one of the most magical things was the scrolling behavior. We could flick a finger to start scrolling, and when we put our finger down again, the scrolling stopped. We weren't just animating a scroll view. We were interacting with a physics simulation.

Timing Curves

While SwiftUI is not necessarily a physics simulation, it does have interruptible animations built into its core. When we animate a view and then animate it to somewhere else, these two animations are combined.

Here we can see what happens when we replace a linear animation with a new linear animation to a different position. This process of replacing an animation with a new animation is called retargeting. We can see that the combined curves turn into a single smooth animation. The curve looks smooth, but the velocity doesn't.

In this example and the other examples on this page, we can control the retarget point by dragging the blue circle.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.linear(duration: 1)) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.35                                  ))            withAnimation(.linear(duration: 1)) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

When we retarget an ease-in-out animation with another ease-in-out animation, the curve looks much smoother, and so does the velocity. This is because the individual curves are smooth as well.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.easeInOut(                              duration: 1                          )) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.35                                  ))            withAnimation(.easeInOut(                              duration: 1                          )) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

In general, we can merge these Bézier-based animations, and the results will be combined.

Springs

Spring animations, such as .spring, .smooth, and .snappy, are a different kind of animation. When a spring animation replaces an existing animation, it tries to take over the velocity of the previous animation. This only works when we replace a spring with another spring. This is closer to the magic of scroll views, where we interact with a physics simulation.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.spring(duration: 1,                                  bounce: 0.3)) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.18                                  ))            withAnimation(.spring(duration: 1,                                  bounce: 0.3)) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

The interesting thing is that the velocity of the previous spring at the retarget point is used as the starting velocity for the new spring. If we look at the graph, we can see that the box still moves a little bit in the old direction, but as the velocity changes, it moves to its new target position.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.smooth(duration: 1)) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.18                                  ))            withAnimation(.bouncy(duration: 1)) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

Mixing Curves

We can also replace an animation with a different kind of curve. For example, we can replace an ease-in-out animation with a spring animation. However, the spring animation will not use the ease-in-out animation's velocity. Instead, it will just start with zero velocity.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.easeInOut(                              duration: 1                          )) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.35                                  ))            withAnimation(.spring(duration: 1,                                  bounce: 0.3)) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

In the other direction, when we take a spring and replace it with an ease-in-out animation, we can see that the two animations are mixed together.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.spring(duration: 2,                                  bounce: 0.3)) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.18                                  ))            withAnimation(.easeInOut(                              duration: 0.5                          )) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…

In general, an animation can choose how to merge with the previous animation. For CustomAnimation conformances you can use the shouldMerge method to implement the behavior you want.

Retargeting Playground

In the playground below, we can play around with the source and destination animations and see how they behave. We can also run the Xcode project to try this with native SwiftUI.

Code
@State private var x = 0.0var body: some View {    Color.red        .frame(width: 48, height: 48)        .offset(x: x)        .task {            withAnimation(.linear(duration: 1)) {                x = 100            }            try? await Task.sleep(for: .seconds(                                      0.35                                  ))            withAnimation(.linear(duration: 1)) {                x = 0            }        }}
Preview
Position
Preparing Swift animations…
Retargeting
From
First duration
1
To
Second duration
1
Retarget at (s)
0.35
New target
0
Start value
0
First target
100

Resources

Share Image

Explore SwiftUI Animation explains model and presentation values, animation curves, and transactions.

https://developer.apple.com/videos/play/wwdc2023/10156/