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 explore SwiftUI's rendering and animation model while building a shake animation.

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 Views 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())
}) { /*...*/ }

/*
SwiftUI.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))

/*
▿ SwiftUI._ShapeView
  - shape: SwiftUI.Rectangle
  ▿ style: purple
    ▿ provider: SwiftUI.(unknown context at $7fff2c66838c).ColorBox #0
      - super: SwiftUI.AnyColorBox
      - base: SwiftUI.SystemColorType.purple
  ▿ fillStyle: SwiftUI.FillStyle
    - isEOFilled: false
    - isAntialiased: true
*/

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))

/*
▿ SwiftUI.ModifiedContent, SwiftUI._FrameLayout>
  ▿ content: SwiftUI._ShapeView
    - shape: SwiftUI.Rectangle
    ▿ style: purple
      ▿ provider: SwiftUI.(unknown context at $7fff2c66838c).ColorBox #0
        - super: SwiftUI.AnyColorBox
        - base: SwiftUI.SystemColorType.purple
    ▿ fillStyle: SwiftUI.FillStyle
      - isEOFilled: false
      - isAntialiased: true
  ▿ modifier: SwiftUI._FrameLayout
    ▿ width: Optional(200.0)
      - some: 200.0
    ▿ height: Optional(200.0)
      - some: 200.0
    ▿ alignment: SwiftUI.Alignment
      ▿ horizontal: SwiftUI.HorizontalAlignment
        ▿ key: SwiftUI.AlignmentKey
          - bits: 140735364014008
      ▿ vertical: SwiftUI.VerticalAlignment
        ▿ key: SwiftUI.AlignmentKey
          - bits: 140735364013985
*/

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))
    }
    // ...
}

/*
-0.008752822875976562
-0.035709381103515625
-0.08180809020996094
-0.14772605895996094
-0.2337169647216797
-0.3394031524658203
-0.4635505676269531
-0.6038570404052734
-0.7568626403808594
-0.9180145263671875
-1.0819854736328125
-1.2431373596191406
-1.3961429595947266
-1.5364513397216797
-1.6605968475341797
-1.7662830352783203
-1.852273941040039
-1.918191909790039
-1.9642906188964844
-1.9912471771240234
-2.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 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.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

136 Episodes · 48h46min

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