SwiftUI: Animation

Mar 29 2022 Swift 5.5, iOS 15, Xcode 13

Part 1: Beginning with SwiftUI Animation

7. Multiple Stages

Lesson Complete

Play Next Lesson
Next
Save for later
About this episode
See versions

See forum comments
Cinema mode Mark as Complete Download course materials
Previous episode: 6. Spinner Next episode: 8. Interactive Animations

This video was last updated on Mar 29 2022

As always, remember that all layout changes on screen in SwiftUI are triggered by changes to your state. Currently, you have a single state property which sets which leaf is currently animated.

SwiftUI can cope with more complex state though. It’s re-rendering everything anyway, so it’s no problem to add more state properties to your spinner view and implement even more complex logic based on that.

In this episode, you’ll make your spinner assemble whenever it has completed its purpose on screen. For example, when the network operation that the spinner represents has completed.

Add one more state property to SpinnerView, to distinguish between being in an “active” animating state, and being in the state of completion.

  @State var currentIndex = -1
  🟩@State var completed = false
  
  var body: some View {

Just as with the currentIndex state property, every change of the value of completed will re-trigger a “snapshot” of your UI and result in animation of any desired changes.

In this episode, we’ll just change to the “completed” state after the timer has fired 30 times. That’ll make for a few seconds of spinning before completing.

Scroll down to the animate() method, and create an iteration variable to keep track of how many times the timer has fired:

  func animate() {
    var iteration = 0

    Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) {
      currentIndex = (currentIndex + 1) % leavesCount

      iteration += 1
    }

If the timer is on the 30th iteration, invalidate it, and switch to the completed state.

      iteration += 1
      if iteration == 30 {
        timer.invalidate()
        completed = true
      }
    }

Watching the live preview, you’ll find that, after a few seconds, the spinner stops animating leaves. After the ones that were already animating all settle down, you have the “completed” state render looking like this.

Notice how the leaf that ended being the current one when you switched to completed state is drawn in white. That’s why you have that “hole”.

So now, you have two stages to your animation that are driven by your state. Let’s reflect that in a more prominent way on screen.

You’re about to make all leaves perform a custom transition when you switch them to completed state. To achieve that, you’ll need to provide the completed state to a Leaf instance.

    let isCurrent: Bool
    let isCompleting: Bool

    var body: some View {

And where the error occurs, pass along the Spinner’s “completed” value.

            isCurrent: index == currentIndex,
            isCompleting: completed
          )

Now that your leaf knows whether it’s being rendered in the completing stage of the animation, you can adjust some of its modifiers to create the new transition.

First, let’s reset each leaf’s rotation whenever you switch to the completed state. That will animate all the leaves back to the initial leaf’s location.

        .scaleEffect(isCurrent ? 0.5 : 1)
        🟩.rotationEffect(isCompleting ? .zero : rotation)
        🟩.animation(.easeIn(duration: 1.5), value: isCompleting)
        .animation(.easeIn(duration: 1.5), value: isCurrent)

Wait until the animation plays out, and you’ll see the leaves neatly fold up at the end of the animation:

Let’s keep iterating on this effect and make the capsule shapes also morph into circles. You can effectively do that by setting the same width and height for the capsule.

        .stroke(isCurrent ? Color.white : .gray, lineWidth: 8)
        🟩.frame(width: 20, height: isCompleting ? 20 : 50)
        .offset(

If you watch carefully, you can see a little artifact during the last moments of the animation. There is one leaf that seems to move towards the center, and fade out, instead of moving with the others. What gives?

This is the one leaf that ended up being the current one when you switched to the completed state. Since it’s the only white leaf it causes a bit of a glitch towards the end of the completion effect.

It’s an easy fix, though. In the timer closure, after you set completed to true, reset currentIndex back to its initial value as well.

        completed = true
        currentIndex = -1
      }

And now, the animation ends cleanly on a single gray circle. This final state looks nice, but it’s time to add one last stage to the animation and learn about view transitions.

You’ve seen how to create SwiftUI animations between different states of views in your view hierarchy. But you can also create animation effects called transitions. They deal with the situations when you add or remove a view to or from your view hierarchy.

Those types of animation effects are created slightly differently because you don’t have two states to interpolate between. When you add or remove a view, you don’t have either a previous or current state, since the view is not in the hierarchy.

Next, once your spinner animation completes, you’ll remove the spinner view from the view hierarchy, and add a transition to animate its way out of the screen.

To achieve this, add one more state property to keep track of the spinner’s presence in the view hierarchy.

  @State var completed = false
  @State var isVisible = true
  
  var body: some View {

While isVisible is set to true, the spinner will be rendered on screen. Once you set isVisible to false, you will remove the view. And that will trigger the “exit” transition animation.

Wrap this ZStack in an if statement, effectively “removing” the spinner when isVisible is false.

  var body: some View {
    VStack {
      if isVisible {
        ZStack {
          ForEach(0..<leavesCount) { index in
            Leaf(
              rotation: .init(degrees: .init(index) / .init(leavesCount) * 360),
              isCurrent: index == currentIndex,
              isCompleting: completed
            )
          }
        }
        .onAppear(perform: animate)
      }
    }
  }

The best place to set isVisible to false is from within the final timer callback. Do that after 2 seconds.

currentIndex = -1
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
  isVisible = false
}

Playing the animation now, you’ll see the leaves disappear after they’re all done folding up.

Hi folks! It’s me, Catie, just coming in one last time to update you. If you want to animate the disappearance, there is a way aside from the animation modifier.

Wrap any changes you want to animate in a call to withAnimation. In this case, you want setting isVisible to false to happen withAnimation

withAnimation {
  isVisible = false
}

Now, when you set up the final transition effect with Jessy, that will animate nicely as well. Off you go!

Next, you’ll add a transition effect to the view using the transition(_) modifier. There are a few predefined transitions you can use, and you can also create a custom transition based on a combination of a few others.

The transition modifier takes an argument of type AnyTransition. Just like with Animation, you can use a few predefined transitions and some handy static methods to create your own:

Opacity is A cross fade effect. Slide: is a transition that slides in views from the left when added, and to the right when removed. .move(edge): moves a view in and out towards the given edge of the screen. offset Creates a transition which moves the view by a given offset. And scale …scales the view up or down.

Additionally, you can combine a number of transitions to create your own custom transition by using the .combined(with:) method. Or, for more advanced timing curves and custom duration, you can combine a transition with an animation by using the animation(_) modifier on a transition instance.

Let’s dive right in and give one of those custom transitions a try. Start off with an offset transition.

  @State var isVisible = true

  let shootUp =
    AnyTransition.offset(x: 0, y: -1000)
  
  var body: some View {

Your custom transition is called shootUp and, as you probably guessed already, will animate the view up and out of the screen by moving it 1000 points upwards.

To give your custom transition a more interesting timing curve, you can combine the offset(_) transition with an animation using an easeIn.

    AnyTransition.offset(x: 0, y: -1000)
    .animation(.easeIn(duration: 1))
  
  var body: some View {

Now, it will start slower, and speed up towards the edge of the screen. The last step you need to make in order to see the new transition on screen is to use it on the ZStack.

        }
        .transition(shootUp)
        .onAppear(perform: animate)

With that, the third and last stage of your spinner animation is finished. Take a moment to enjoy the amazing result you achieved, with hardly any code at all!