00:06 Today we'll build a shake animation in SwiftUI, which is less
straightforward than it seems — at least, to us. Naturally, we'll need to go
about this differently than we would in UIKit. While figuring out how to
implement the animation, we'll learn more about how SwiftUI works.
Animation
00:30 We start out with an empty project, and we add a purple rectangle
and a button to the content view:
struct ContentView: View {
var body: some View {
VStack {
Button(action: {
}) { Text("Shake!") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
}
}
}
01:48 In UIKit, our next step would be to keep a reference to the
rectangle and use it to trigger an animation from the button's action closure.
But we don't have a reference to the rectangle in SwiftUI, because SwiftUI
View
s are simple structs that don't have a direct link to the actual views
that are onscreen.
02:27 The way we change something onscreen is by changing the state,
thus triggering a rerender. And the easiest way to change the state is by
toggling a Bool
. So instead of trying to shake the rectangle, let's first
pretend we can select and deselect the rectangle. We store this state as a
Bool
. Inside the view, we use this state to change the button's label:
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
}
}
}
03:36 It seems that SwiftUI is smart about rerendering and only updates
the label's text. We can also pick a different fill color based on the selection
state, as well as apply an offset to the rectangle. Now SwiftUI makes all three
changes when we tap the button:
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(selected ? Color.green : Color.purple)
.frame(width: 200, height: 200)
.offset(y: selected ? -300 : 0)
}
}
}
04:17 Finally, we can add an animation that interpolates between the two
states. Instead of jumping from one value to the other, the animatable
properties now gradually change over time:
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(selected ? Color.green : Color.purple)
.frame(width: 200, height: 200)
.offset(y: selected ? -300 : 0)
.animation(.default)
}
}
}
04:58 It feels almost magical that SwiftUI knows how to do this, and we
don't mean that in a good way. How and why does this work exactly?
05:14 By just observing the behavior — we see the rectangle changing
from green to purple and back, and its offset goes from 0
to -300
and back —
it's apparent that SwiftUI knows how to interpolate between values, and it
calculates a number of steps between the first and last frame of the animation.
Shaking
05:46 So we understand how to animate from one state to the next. Let's
now go back to our goal. Instead of switching between two states, we want to
trigger a one-time shake animation. We can repeatedly trigger the animation, but
after each shake, we should end up in the same state we started in.
06:17 Let's see how far we get with the tools SwiftUI gives us. We
remove the change in color and we move the rectangle 30 points to the left if
the selected
property equals true
:
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.offset(x: selected ? -30 : 0)
.animation(.default)
}
}
}
06:37 By repeating the animation five times and speeding it up, it
starts to look like a shake:
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.offset(x: selected ? -30 : 0)
.animation(Animation.default.repeatCount(5).speed(3))
}
}
}
07:15 The rectangle now moves back and forth five times until it ends
up 30 points left of the center, but we want it to end up where it started.
07:30 If we tell the animation to repeat six times, the rectangle
animates back to the center, but directly after that, it makes a hard jump back
to the end position, which is 30 points to the left. This is worse, because the
end of the animation doesn't match up with the actual value that we're supposed
to be animating to.
07:55 We might want to set the offset back to zero the moment the
animation finishes, but that's impossible because Animation
doesn't have a
completion callback. And it would feel like a hack, so there must be a better
way.
Inspecting Internals
08:16 We need an approach that's different than setting an offset and
repeating the implicit animation. In order to understand where we should
implement our custom animation, we'll take a look under the hood at how our view
struct is built up. We abuse the button's action closure to dump a description
of the rectangle to the console:
Button(action: {
self.selected.toggle()
dump(Rectangle())
}) { }
09:23 This just prints SwiftUI.Rectangle
without any properties.
Looking at the definition of Rectangle
, we can see that, indeed, it doesn't
have any properties; it only has a function that returns a path that fills up
the space it's given by the parent view.
10:12 In UIKit, we're used to setting properties, such as color and
position, on the rectangle. But that's not how SwiftUI works, and this is clear
when we look at the result of applying the color modifier:
dump(Rectangle().fill(Color.purple))
10:35 SwiftUI puts a layer around the rectangle, resulting in a
SwiftUI._ShapeView<SwiftUI.Rectangle, SwiftUI.Color>
— a shape view that
contains the rectangle and the fill color. It seems that, in order to render
itself, the shape view sets the value of its color property as the fill color
and then asks the rectangle to draw itself.
11:09 This goes on with the next step; when we apply the frame
modifier, the result is even more complex:
dump(Rectangle().fill(Color.purple).frame(width: 200, height: 200))
11:46 There's another layer wrapped around what we had before, and this
layer just constrains its contents to a 200-by-200-point frame. Just like the
rectangle shape and the fill color, each layer is only responsible for one
aspect of the final result.
12:40 This concept, that of the single responsibility principle, can
help us with the animation as well. Perhaps we can add a layer that shakes its
contents, and this layer can then expose a property that we can modify from the
outside in order to trigger a shake.
12:57 Instead of offset
, we apply a custom modifier that we will write
ourselves:
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.modifier(ShakeEffect())
.animation(Animation.default.repeatCount(6).speed(3))
13:27 A modifier can be anything that conforms to the ViewModifier
protocol. Because we want to apply a translation effect, we can conform to a
more specific protocol — GeometryEffect
— which inherits from ViewModifier
:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
}
}
14:19 After the compiler adds the protocol stubs, we see that we need to
implement the effectValue(size:)
method, which returns a
ProjectionTransform
.
14:32 We can choose between two initializers of ProjectionTransform
;
the first one takes a CATransform3D
, and the second one takes a
CGAffineTransform
. We pick the latter to define a simple translation:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -20, y: 0))
}
}
15:19 This applies the translation to the rectangle immediately, but we
want to animate the translation. Just like before, we can pass different values
into our modifier based on the state:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: position, y: 0))
}
var position: CGFloat
}
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.modifier(ShakeEffect(position: selected ? -20 : 0))
.animation(Animation.default.repeatCount(6).speed(3))
16:32 In order to animate the change, we have to implement the
animatableData
property in conformance with the Animatable
protocol. Looking
at the protocol definition, we see that the property's type must conform to
VectorArithmetic
, meaning it implements addition, subtraction, and scaling.
These combined operations allow SwiftUI to find the difference between two
values and calculate a number of in-between steps for the animation's frames.
18:02 The position
property that drives the change is of type
CGFloat
, which already conforms to VectorArithmetic
, so we can pass
position
on as the animatable data:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: position, y: 0))
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
18:23 Now the rectangle animates to the left instead of jumping. After
we remove the repetitions and speed from the animation, we can also print out
the value of position
so that we see how it changes over time:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
print(position)
return ProjectionTransform(CGAffineTransform(translationX: position, y: 0))
}
}
19:35 We change the animation type to .linear
so that the
ShakeEffect
receives a value that linearly changes from 0
to 1
.
21:08 Now we can define our shake animation on this value. By feeding
the position
value into a sine function, we can create a back-and-forth
motion. To remind ourselves of how a sine looks, we use Grapher, a built-in tool
on macOS, to plot a curve:
22:04 Following the curve, we can see one complete period between -π
and π
. We can multiply the x
value by 2π
so that the curve makes one
complete period between 0
and 1
:
23:06 Back in our code, we implement the same function:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
}
23:27 When we hit the button, the rectangle moves left, then right, and
then back to the center. We then multiply the position
parameter by two in
order to repeat the same motion twice so that it looks more like a shake:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 4 * .pi), y: 0))
}
}
Improving the API
24:04 As a last step, we can think about the API we want the
ShakeEffect
to have. Instead of passing in an arbitrary offset value, we could
take an Int
representing the number of shakes:
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
init(shakes: Int) {
position = CGFloat(shakes)
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
struct ContentView: View {
@State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.modifier(ShakeEffect(shakes: selected ? 2 : 0))
.animation(Animation.linear)
}
}
}
This is slightly better, but the API still feels a bit weird, because we're
changing the shake from zero to one, and back.
25:21 Instead of a Bool
state, perhaps it would make more sense to
have an Int
. We can imagine that the rectangle is actually a login form that
shakes each time we submit invalid credentials. In the view, we keep track of
the number of invalid login attempts, and we pass this number on to the
ShakeEffect
:
struct ContentView: View {
@State var invalidAttempts = 0
var body: some View {
VStack {
Button(action: {
self.invalidAttempts += 1
}) { Text("Shake") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.modifier(ShakeEffect(shakes: invalidAttempts * 2))
.animation(Animation.linear)
}
}
}
The rectangle now shakes as we increase the number of invalid login attempts.
Conclusion
26:22 It takes some getting used to the logic of triggering an event by
updating a value. In this case, we're incrementing a number to trigger a shake
animation, but the animation actually only cares about the change and not the
number.
27:16 Not all of SwiftUI is like this; the onAppear
modifier, which
takes a closure that is executed at a certain event, is more closely related to
imperative programming.
27:24 But most of SwiftUI's APIs, and especially the animation APIs,
are very declarative. At first, we found it hard to figure out how to model the
behavior we wanted in these APIs. But once we found the solution, we ended up
with code that's reasonable and easy to work with.