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 use a custom animation to manually control the progress of animations using a slider.

00:06 We're not in the studio today because we've had some water damage and everything is moved out to open up the floor. So, as a temporary solution, we're recording this episode remotely.

00:37 Today, we want to make it easier to debug animations. In SwiftUI, animations occur when there's a state change and either the state change has a transaction with an explicit animation — this is the case when we use withAnimation or withTransaction — or the view that gets updated by the state change contains an implicit animation — this is what happens when we use the .animation modifier.

01:15 The problem is that we can't easily replay or debug these animations. The best debugging technique available to us is taking a screen recording and scrubbing through that. But what we'd really like is to have a slider that controls the progress of an animation. We'll try to build this, inspired by an old trick from people working with Core Animation, where they just used an extremely slowed-down animation.

Sample Animation

02:08 First, let's set up a view with an animation in it. We create a red rectangle, and we change it to blue when we toggle a Boolean state property:

struct ContentView: View {
    @State private var toggle = false

    var body: some View {
        VStack {
            Rectangle()
                .fill(toggle ? Color.red : .blue)
                .frame(width: 200, height: 200)
                .onTapGesture {
                    toggle.toggle()
                }
            Spacer()
        }
        .padding()
    }
}

03:10 The rectangle's fill color now instantly switches between red and blue each time we tap. If we add an implicit animation, the changes to the fill color will be animated:

struct ContentView: View {
    @State private var toggle = false

    var body: some View {
        VStack {
            Rectangle()
                .fill(toggle ? Color.red : .blue)
                .frame(width: 200, height: 200)
                .animation(.easeInOut, value: toggle)
                .onTapGesture {
                    toggle.toggle()
                }
            Spacer()
        }
        .padding()
    }
}

03:38 This goes for any animatable property. For example, we can also change the frame's width based on the toggle state, and this will make the rectangle grow wider and smaller with the same animation curve:

struct ContentView: View {
    @State private var toggle = false

    var body: some View {
        VStack {
            Rectangle()
                .fill(toggle ? Color.red : .blue)
                .frame(width: toggle ? 200 : 100, height: 200)
                .animation(.easeInOut, value: toggle)
                .onTapGesture {
                    toggle.toggle()
                }
            Spacer()
        }
        .padding()
    }
}

03:55 Basically, SwiftUI takes a snapshot of the view's attribute graph before the state change and a snapshot after the state change; it compares the individual animatable properties in the nodes; and it interpolates those values to create an animation — in this case, the width of the frame and the fill color of the rectangle.

04:48 To better inspect this animation, we want to be able to pause the animation at any point in between the two snapshots. The problem is that our state is a Boolean, and we cannot set this to an in-between value: it's always going to be either true or false. So we can't use the state to manually set the progress of our animation, but let's see what we can do with a custom animation.

CustomAnimation

05:16 We can define a custom animation curve by conforming a struct to the CustomAnimation protocol. This protocol's animate method will be called for each animatable property in the view — so once with a CGFloat for the frame width, and once with a Color value for the fill color. But inside the method, we only know that we're dealing with a value conforming to VectorArithmetic, meaning it can be scaled. By scaling the value to 0.5, we should end up in the middle between the two snapshots of the animatable values:

struct ConstantAnimation: CustomAnimation {
    func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {
        return value.scaled(by: 0.5)
    }
}

06:35 When we add this animation to the view and we tap the rectangle to trigger the state change, the rectangle ends up halfway through the animation:

Controlling the Progress

07:29 We can move the ConstantAnimation into a view modifier, where we control the animation's progress with a slider. To see the effects of changing the progress, we need to find a way to keep triggering the animation as the slider moves. Perhaps we could first set the toggle Boolean to false and then set it back to true in a withAnimation block.

08:35 First, we add a progress property to the ConstantAnimation, and we use this value as the scale factor in the animate method:

struct ConstantAnimation: CustomAnimation {
    var progress: Double

    func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {
        return value.scaled(by: progress)
    }
}

09:03 Then we write a view modifier that applies the ConstantAnimation. The modifier takes a Binding to a Boolean state, so that it can update that state to trigger a new animation:

struct DebugAnimation: ViewModifier {
    @Binding var state: Bool
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .animation(anim, value: state)

    }
}

10:21 Whenever progress changes, we mutate state with an explicit animation to update the view. That means we don't need the implicit animation:

struct DebugAnimation: ViewModifier {
    @Binding var state: Bool
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .onChange(of: progress) {
                state = false
                withAnimation(anim) {
                    state = true
                }
            }
    }
}

10:41 Next, we want to change the progress value with a slider, which we add in an overlay. By using the .bottom alignment in the overlay and overriding the slider's alignment value to its top, we place the slider just below the content view:

struct DebugAnimation: ViewModifier {
    @Binding var state: Bool
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .onChange(of: progress) {
                state = false
                withAnimation(anim) {
                    state = true
                }
            }
            .overlay(alignment: .bottom) {
                Slider(value: $progress, in: 0...1)
                    .frame(width: 200, height: 40)
                    .alignmentGuide(.bottom, computeValue: { dimension in
                        dimension[.top]
                    })
            }
    }
}

11:48 Finally, we apply the modifier to our view:

struct ContentView: View {
    @State private var toggle = false
    
    var body: some View {
        VStack {
            Rectangle()
                .fill(toggle ? Color.red : .blue)
                .frame(width: toggle ? 200 : 100, height: 200)
                .modifier(DebugAnimation(state: $toggle))
            Spacer()
        }
        .padding()
    }
}

12:11 Something weird happens when we use the slider — the rectangle's width and color are set to some value outside the 0 to 1 range — and we think this may be caused by multiple animations added on top of each other. We can try overwriting the previous animations by implementing the shouldMerge method of the CustomAnimation protocol:

struct ConstantAnimation: CustomAnimation {
    var progress: Double
    
    func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {
        print(value)
        return value.scaled(by: progress)
    }

    func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool where V : VectorArithmetic {
        return true
    }
}

13:01 Unfortunately, that doesn't fix the issue. What does work is inserting an implicit animation before the onChange call:

struct DebugAnimation: ViewModifier {
    @Binding var state: Bool
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .animation(anim, value: state)
            .onChange(of: progress) {
                state = false
                withAnimation(anim) {
                    state = true
                }
            }
            .overlay(alignment: .bottom) {
                // ...
            }
    }
}

13:41 This seems to fix the issue because the implicit animation takes precedence over the explicit animation from the withAnimation call. The explicit animation isn't actually used; we just have to keep it there to create a state change when the slider moves:

struct DebugAnimation: ViewModifier {
    @Binding var state: Bool
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .animation(anim, value: state)
            .onChange(of: progress) {
                state = false
                withAnimation {
                    state = true
                }
            }
            .overlay(alignment: .bottom) {
                // ...
            }
    }
}

14:27 It doesn't make much sense to us that the implicit animation is needed, but then again, we're sort of hacking the system to create a controllable animation. At least we now get to see the exact interpolation behavior, which is really cool.

More Animations

14:53 We've now managed to "debug" a very simple example of an animation. But it'd be interesting to see if we can also make this work with a matched geometry effect. The matched geometry effect can be used to transition from one view to another. In doing so, it updates both the view being removed and the one being inserted, and this is all driven by an animation. Let's see if we can control this animation as well.

15:52 Based on the toggle state, we show one of two views — a smaller, red rectangle, or a larger, blue rectangle. We can also move the stack view containing the rectangles back and forth by putting a wider frame around it and changing its alignment:

struct ContentView: View {
    @State private var toggle = false
    
    var body: some View {
        VStack {
            if toggle {
                Color.red
                    .frame(width: 50, height: 50)
            } else {
                Color.blue
                    .frame(width: 100, height: 100)
            }
        }
        .frame(maxWidth: .infinity, alignment: toggle ? .leading : .trailing)
        .padding()
    }
}

16:35 Then we attach our DebugAnimation modifier, and we push everything up again by adding a Spacer to the VStack:

struct ContentView: View {
    @State private var toggle = false
    
    var body: some View {
        VStack {
            ZStack {
                if toggle {
                    Color.red
                        .frame(width: 50, height: 50)

                } else {
                    Color.blue
                        .frame(width: 100, height: 100)
                }
            }
            .frame(height: 200)
            .frame(maxWidth: .infinity, alignment: toggle ? .leading : .trailing)
            .modifier(DebugAnimation(state: $tapCount, from: 0, to: 1))

            Spacer()
        }
        .padding()
    }
}

17:48 Now we can already see the views fading in and out as we move the slider. We can also use a transition other than the default .opacity one:

Color.blue
    .frame(width: 100, height: 100)
    .transition(.opacity.combined(with: .slide))

18:17 But we actually wanted to let a matched geometry effect drive the transition, so we define a namespace, and we call matchedGeometryEffect on both rectangles. It's important that we place these calls directly on the color shapes, because if we'd put them after the frames, the views wouldn't be flexible and the scaled frames set by the matched geometry effect wouldn't have any effect on the shapes:

struct ContentView: View {
    // ...
    var body: some View {
        VStack {
            ZStack {
                if toggle {
                    Color.red
                        .matchedGeometryEffect(id: "ID", in: ns)
                        .frame(width: 50, height: 50)

                } else {
                    Color.blue
                        .matchedGeometryEffect(id: "ID", in: ns)
                        .frame(width: 100, height: 100)
                }
            }
            // ...
        }
        .padding()
    }
}

19:11 The slider lets us see each point of the transition created by the matched geometry effect — very cool! If we put the slider in the middle, we can see that both views are semitransparent, and we can see the background through them. This becomes even clearer when we add another view behind them, like a text view:

struct ContentView: View {
    // ...
    var body: some View {
        VStack {
            ZStack {
                if toggle {
                    Color.red
                        .matchedGeometryEffect(id: "ID", in: ns)
                        .frame(width: 50, height: 50)

                } else {
                    Color.blue
                        .matchedGeometryEffect(id: "ID", in: ns)
                        .frame(width: 100, height: 100)
                }
            }
            .frame(height: 200)
            .frame(maxWidth: .infinity, alignment: toggle ? .leading : .trailing)
            .background {
                Text("Hello")
                    .font(.largeTitle)
            }
            .modifier(DebugAnimation(state: $tapCount, from: 0, to: 1))

            Spacer()
        }
        .padding()
    }
}

19:54 The text is visible behind the rectangles, because the half opacities of both rectangles don't add up to something fully opaque. If we were animating something like a photo, we'd probably use an .identity transition so that it doesn't fade in and out, but for a changing color, the fade works nicely.

Making DebugAnimation Generic

20:43 We can do one little trick to make the animation debugger a bit more generic, so that it works with types of state other than Booleans. We add a generic type parameter, and we also need from and to values to mutate the state with:

struct DebugAnimation<Value: Equatable>: ViewModifier {
    @Binding var state: Value
    var from, to: Value
    @State private var progress: Double = 0

    func body(content: Content) -> some View {
        let anim = Animation(ConstantAnimation(progress: progress))
        content
            .animation(anim, value: state)
            .onChange(of: progress) {
                state = from
                withAnimation(anim) {
                    state = to
                }
            }
            .overlay(alignment: .bottom) {
                Slider(value: $progress, in: 0...1)
                    .frame(width: 200, height: 40)
                    .alignmentGuide(.bottom, computeValue: { dimension in
                        dimension[.top]
                    })
            }
    }
}

21:31 Where we use the modifier, we have to provide the false and true values:

.modifier(DebugAnimation(state: $toggle, from: false, to: true))

21:45 But we could now easily replace our state property with an integer, e.g. tapCount, and toggle between states by checking if the count is a multiple of 2. We also have to tell the modifier to update the state from 0 to 1 to trigger the animation:

struct ContentView: View {
    @State private var tapCount = 0
    @Namespace var ns
    
    var body: some View {
        let toggle = tapCount.isMultiple(of: 2)
        VStack {
            ZStack {
                // ...
            }
            .frame(height: 200)
            .frame(maxWidth: .infinity, alignment: toggle ? .leading : .trailing)
            .background {
                Text("Hello")
                    .font(.largeTitle)
            }
            .modifier(DebugAnimation(state: $tapCount, from: 0, to: 1))

            Spacer()
        }
        .padding()
    }
}

22:31 The last step is a reminder that we can't write this view modifier as a drop-in replacement for .animation, because it needs to capture information about the state and how it can be mutated to trigger animation updates.

22:55 Fingers crossed that this will be the only virtual episode, and that we can do one in person again next week!

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

163 Episodes · 56h31min

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