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 frame
s, 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!