Animations in SwiftUI

SwiftUI animations have been kicked up a notch with some dedicated new APIs for phase based, and keyframe based, animations.

Lets jump right into how these work.

Phased Animations

Phased animations are based on iterating over pre-defined phases. These phases can be thought of as "steps" to your animations.

For the simplest example, lets think about a shape that fades in and out. It has two phases, out and in., so lets call those invisible and visible.

We can get started by modelling our phases, and I think the best way to do this, is with an enum.

enum SimpleAnimationPhase: CaseIterable {
    case invisible, visible
}
enum SimpleAnimationPhase: CaseIterable {
    case invisible, visible
}

Next, lets look at how we can use this - say hello to PhaseAnimator.

Phase animator will iterate over our enum, and animate the changes.

The simplest possible implementation looks like this.

struct SimplePhasedAnimation: View {
    var body: some View {
        PhaseAnimator(
            SimpleAnimationPhase.allCases
        ) { phase in
            RoundedRectangle(cornerRadius: 12)
                .opacity(phase == .invisible ? 0 : 1)
        }
    }
}
struct SimplePhasedAnimation: View {
    var body: some View {
        PhaseAnimator(
            SimpleAnimationPhase.allCases
        ) { phase in
            RoundedRectangle(cornerRadius: 12)
                .opacity(phase == .invisible ? 0 : 1)
        }
    }
}

Running that will give you an infinitely looping animation, but it'll look a little weird.

Lets extend our usage of PhaseAnimator, passing a second closure for animations, where we'll return the new bouncy by default.

PhaseAnimator(
    SimpleAnimationPhase.allCases,
    content: { phase in
        RoundedRectangle(cornerRadius: 12)
            .opacity(phase == .invisible ? 0 : 1)
    }, animation: { phase in
        return .bouncy
    }
)
PhaseAnimator(
    SimpleAnimationPhase.allCases,
    content: { phase in
        RoundedRectangle(cornerRadius: 12)
            .opacity(phase == .invisible ? 0 : 1)
    }, animation: { phase in
        return .bouncy
    }
)

We can do something really cool here - we can return an animation specific for the given phase. Lets update our code so we fade in slowly, and fade out quickly.

PhaseAnimator(
    SimpleAnimationPhase.allCases,
    content: { phase in
        RoundedRectangle(cornerRadius: 12)
            .opacity(phase == .invisible ? 0 : 1)
    }, animation: { phase in
        switch phase {
        case .invisible:
            return .easeInOut(duration: 1)
        case .visible:
            return .easeInOut(duration: 2)
        }
    }
)
PhaseAnimator(
    SimpleAnimationPhase.allCases,
    content: { phase in
        RoundedRectangle(cornerRadius: 12)
            .opacity(phase == .invisible ? 0 : 1)
    }, animation: { phase in
        switch phase {
        case .invisible:
            return .easeInOut(duration: 1)
        case .visible:
            return .easeInOut(duration: 2)
        }
    }
)

There's a lot of possibilities here. For example, you could use a custom curve, a spring, mess with durations, and even add delays.

So far, our animation runs in perpetuity, but what if we want to control it? There's an option for that, where we can provide a trigger. This value, on change, will cause the animation to fire.

struct SimplePhasedAnimation: View {
    @State var animate: Bool = false

    var body: some View {
        VStack {
            PhaseAnimator(
                SimpleAnimationPhase.allCases,
                trigger: animate,
                content: { phase in
                    RoundedRectangle(cornerRadius: 12)
                        .opacity(phase == .invisible ? 0 : 1)
                }, animation: { phase in
                    switch phase {
                    case .invisible:
                        return .easeInOut(duration: 1)
                    case .visible:
                        return .easeInOut(duration: 2)
                    }
                }
            )

            Button(action: {
                animate.toggle()
            }, label: {
                Text("Animate")
            })
        }
    }
}
struct SimplePhasedAnimation: View {
    @State var animate: Bool = false

    var body: some View {
        VStack {
            PhaseAnimator(
                SimpleAnimationPhase.allCases,
                trigger: animate,
                content: { phase in
                    RoundedRectangle(cornerRadius: 12)
                        .opacity(phase == .invisible ? 0 : 1)
                }, animation: { phase in
                    switch phase {
                    case .invisible:
                        return .easeInOut(duration: 1)
                    case .visible:
                        return .easeInOut(duration: 2)
                    }
                }
            )

            Button(action: {
                animate.toggle()
            }, label: {
                Text("Animate")
            })
        }
    }
}

Everything you already know about animations works here too, and you can build something lovely using transitions.

For an advanced example, lets look at the code for a view that animates in three squares one by one, accellerating towards the end.

enum SquareAnimationPhase: Int, CaseIterable, Comparable {
    case none, left, middle, right

    static func < (lhs: SquareAnimationPhase, rhs: SquareAnimationPhase) -> Bool {
        lhs.rawValue < rhs.rawValue
    }
}

struct SquarePhasedAnimation: View {
    @State var animate: Bool = false

    var body: some View {
        VStack {
            PhaseAnimator(
                SquareAnimationPhase.allCases,
                trigger: animate,
                content: { phase in
                    HStack {
                        if phase >= .left {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.cyan)
                                .transition(.scale.combined(with: .opacity))
                        }
                        if phase >= .middle {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.teal)
                                .transition(.scale.combined(with: .opacity))
                        }

                        if phase >= .right {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.blue)
                                .transition(.scale.combined(with: .opacity))
                        }
                    }
                    .frame(height: 100)
                    .padding()
                }, animation: { phase in
                    switch phase {
                    case .none: return .bouncy.delay(1)
                    case .left: return .spring(duration: 0.6)
                    case .middle: return .spring(duration: 0.4)
                    case .right: return .spring(duration: 0.3)
                    }
                }
            )

            Button(action: {
                animate.toggle()
            }, label: {
                Text("Animate")
            })
        }
    }
}
enum SquareAnimationPhase: Int, CaseIterable, Comparable {
    case none, left, middle, right

    static func < (lhs: SquareAnimationPhase, rhs: SquareAnimationPhase) -> Bool {
        lhs.rawValue < rhs.rawValue
    }
}

struct SquarePhasedAnimation: View {
    @State var animate: Bool = false

    var body: some View {
        VStack {
            PhaseAnimator(
                SquareAnimationPhase.allCases,
                trigger: animate,
                content: { phase in
                    HStack {
                        if phase >= .left {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.cyan)
                                .transition(.scale.combined(with: .opacity))
                        }
                        if phase >= .middle {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.teal)
                                .transition(.scale.combined(with: .opacity))
                        }

                        if phase >= .right {
                            RoundedRectangle(cornerRadius: 12)
                                .foregroundStyle(.blue)
                                .transition(.scale.combined(with: .opacity))
                        }
                    }
                    .frame(height: 100)
                    .padding()
                }, animation: { phase in
                    switch phase {
                    case .none: return .bouncy.delay(1)
                    case .left: return .spring(duration: 0.6)
                    case .middle: return .spring(duration: 0.4)
                    case .right: return .spring(duration: 0.3)
                    }
                }
            )

            Button(action: {
                animate.toggle()
            }, label: {
                Text("Animate")
            })
        }
    }
}

Here we're taking advantage of transitions to make the rectangles look fun when they appear, and we've altered the duration of our spring animations so that they feel like they plop in together nicely.

Note that your view starts from the first state and animates through the rest, so our delay here will only apply after the other animations have finished. I've found the best animations can come from adding this "empty" phase.

For an advanced example of phased, checkout the code sample repositiory, where i've built out a simple animation based on the one found in Health.

Keyframe Animations

Unil now, SwiftUI keyframe animations have essentialy been hacking dispatch queues - no more. There's a lovely new way you can power animations with keyframes, and you get quite a lot of control over them.

We'll again get started with modelling, except this time, we'll be making a struct that contains a couple values. We're going to build a cube that grows, moves, and rotates, so we need a model for all of that.

private struct Values {
    var scale: Double = 1
    var rotation = Angle.zero
    var offset: Double = 0
}
private struct Values {
    var scale: Double = 1
    var rotation = Angle.zero
    var offset: Double = 0
}

There's nothing speical about these models, except, the values must be Animateble.

Next, lets meet the new animator. The API is fairly similar, except, it takes an instance of our Values instead of an array, and has a keyframe closure instead of animations.

KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        ...
    }, keyframes: { values in
        ...
    }
)
KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        ...
    }, keyframes: { values in
        ...
    }
)

We provide our initial values, which should be preconfigured with defaults, and then we're expected to provide both the content and the animations.

Lets get started by adding a simple square that will grow. The content for this is no different for usual, except, we need to make sure we're using the values provided to us to drive a modifier, such as scaleEffect.

KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        RoundedRectangle(cornerRadius: 12)
            .frame(width: 100, height: 100)
            .scaleEffect(values.scale)
    }, keyframes: { values in
        ...
    }
)
KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        RoundedRectangle(cornerRadius: 12)
            .frame(width: 100, height: 100)
            .scaleEffect(values.scale)
    }, keyframes: { values in
        ...
    }
)

Next, let's animate it.

Animations are a litle more hands on here, but that allows for you to have some fun with it.

We need to define a KeyframeTrack, which will track a given value, and let us provide keyframes for it.

Lets look at the track for our scale.

KeyframeTrack(\.scale) {
    SpringKeyframe(0.5, spring: .bouncy(duration: 1))
    SpringKeyframe(1, spring: .bouncy(duration: 1))
}
KeyframeTrack(\.scale) {
    SpringKeyframe(0.5, spring: .bouncy(duration: 1))
    SpringKeyframe(1, spring: .bouncy(duration: 1))
}

We've initialised a track with the keypath we want to watch from Values, and then added some keyframes that set the value to the first argument, and animate using the second.

There's quite a few options for keyframes, and I'd reccomend that you play with these to see what feels right for your animation. Today, I'm going to stick to Spring and Linear.

SpringKeyframe(0.5, spring: .bouncy(duration: 1))
LinearKeyframe(2, duration: 2)
CubicKeyframe(3, duration: 2)
MoveKeyframe(3)
SpringKeyframe(0.5, spring: .bouncy(duration: 1))
LinearKeyframe(2, duration: 2)
CubicKeyframe(3, duration: 2)
MoveKeyframe(3)

Lets look at a complete animator. I've switched out the springs for smooth ones.

KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        RoundedRectangle(cornerRadius: 12)
            .frame(width: 100, height: 100)
            .scaleEffect(values.scale)
    }, keyframes: { values in
        KeyframeTrack(\.scale) {
            SpringKeyframe(0.8, spring: .smooth)
            SpringKeyframe(1.2, spring: .smooth)
        }
    }
)
KeyframeAnimator(
    initialValue: Values(),
    content: { values in
        RoundedRectangle(cornerRadius: 12)
            .frame(width: 100, height: 100)
            .scaleEffect(values.scale)
    }, keyframes: { values in
        KeyframeTrack(\.scale) {
            SpringKeyframe(0.8, spring: .smooth)
            SpringKeyframe(1.2, spring: .smooth)
        }
    }
)

We also have the option to toggle this based on a changing value, just like we do with PhaseAnimator.

Our animations can track multiple values at once, and thats where the fun starts. We can add tracks for multiple values, so lets add tracks for all of our properties.

I'll add some that make the cube move up and shake a little alongside the existing animations.

KeyframeTrack(\.scale) {
    SpringKeyframe(0.8, spring: .smooth(duration: 0.6))
    SpringKeyframe(1.2, spring: .smooth)
}

KeyframeTrack(\.rotation) {
    CubicKeyframe(.degrees(-45), duration: 0.4)
    CubicKeyframe(.degrees(45), duration: 0.4)
    CubicKeyframe(.zero, duration: 0.3)
}

KeyframeTrack(\.offset) {
    SpringKeyframe(-20, duration: 0.8)
    SpringKeyframe(0, duration: 0.3)
}
KeyframeTrack(\.scale) {
    SpringKeyframe(0.8, spring: .smooth(duration: 0.6))
    SpringKeyframe(1.2, spring: .smooth)
}

KeyframeTrack(\.rotation) {
    CubicKeyframe(.degrees(-45), duration: 0.4)
    CubicKeyframe(.degrees(45), duration: 0.4)
    CubicKeyframe(.zero, duration: 0.3)
}

KeyframeTrack(\.offset) {
    SpringKeyframe(-20, duration: 0.8)
    SpringKeyframe(0, duration: 0.3)
}

This gives us a really fun animation, where the cube shrinks, moves up, wiggles, then comes down. Its easy to see how you can have some fun with this!

There's plenty you can do with this, and I hope you enjoy it!


You can find the demo app here.

If you fancy reading a little more, or sharing your experiences, I’m @SwiftyAlex on twitter.