Chapters

Hide chapters

iOS Animations by Tutorials

Seventh Edition · iOS 15 · Swift 5.5 · Xcode 13

Section IV: Layer Animations

Section 4: 9 chapters
Show chapters Hide chapters

2. Intermediate SwiftUI Animations
Written by Marin Todorov

You hopefully completed the previous chapter successfully and worked through the exercises too — you’re on the path to becoming a great SwiftUI animator!

As you remember, SwiftUI works by you declaring how your UI should look for a given set of data. If the data changes, your UI is rebuilt. You can add modifiers to part or all of the UI to let the framework know that, if those parts change, you’d like the change to be animated. Your job is mostly to describe what kind of animation (basic or spring) you would like for SwiftUI to automatically apply between the “snapshots” of your view hierarchy when you make changes to your state data.

There is not much more than this basic principle to learn in order to create stunning animations with SwiftUI! That’s a rather stark difference when comparing with animations via UIKit or AppKit.

In this chapter, you will iterate over drawing shapes and animating them on screen. You will also learn about view transitions, and will create a more complex, interactive multi-stage animation.

Getting Started

The final animation you will build by working through the end of this chapter will be a modern, fluid spinner-view that will look like this:

The beginnings of that spinner, however, are rather humble. To get started, open the starter project for this chapter and click SpinnerView.swift. Then, check out the starter view layout in the Editor Canvas:

This is where you start on your path to the spinner view you saw earlier. Currently on screen, you see a single ellipse shape that we’ll call a “leaf” throughout this chapter.

Have a look at the starter code of SpinnerView and observe that:

  • Leaf is a view type nested inside SpinnerView — since views are just structs, you can treat them as any other type in your code base. You can nest views, make them public or private, make them conform to further protocols besides View, etc.
  • Leaf draws itself by using a view type called Capsule. Capsule along with Circle, RoundedRectangle and Rectangle allow you to easily draw shapes, or use them as clipping masks.
  • The SpinnerView body is currently very simple, it includes a single leaf shape and, once added on screen, calls its animate(_:value:) method which is currently empty.

The code is all set for you to jump in and add some coolness.

Drawing the Spinner

Your first task is to draw the static spinner on screen. This will give you some insight into how to compose shape views and hopefully give you ideas how to design your own shape animations in future.

First of all, find the line of code which adds the initial leaf on screen Leaf() and delete it. In its place insert a loop which creates as many leaves as the leavesCount constant:

ForEach(0..<leavesCount) { index in
  Leaf()
}

Cool! That code will draw twelve identical leaves on screen. In fact all of them are so identical that when drawn over each other they look exactly the same as that initial identical leaf:

No worries, a few more tweaks and the spinner view will start taking shape. First of all, in order to see more than a single leaf drawn on screen, let’s rotate each one slightly off the previous.

First, add a property on the Leaf type which will help us set the rotation of the capsule shape. Add inside Leaf:

let rotation: Angle

Swift will automatically generate a Leaf(rotation:) initializer for your leaf view and you will need to adjust the code that creates the leaves accordingly. Scroll down a bit and, inside the ForEach closure, replace Leaf() with:

Leaf(rotation: Angle(degrees:
  (Double(index) / Double(self.leavesCount)) * 360.0)
)

This will calculate the delta angle between each of the leaves so that, when all of them are drawn on screen, they will form a full circle.

You don’t see on screen any “full circle” right now, though, since you haven’t added the rotation view modifier yet. Move over to the code in the leaf’s body property and add directly after the frame(width:height:) modifier:

.rotationEffect(rotation)

This modifier will apply the injected rotation to the view.

Now you will finally see something change in the Editor Canvas (you might need to click the Resume button towards the top of the canvas in case Xcode paused updates while you were adding your code.)

You can definitely see a bunch of the leaves drawn over each other. To make them even more distinguishable, blow them apart slightly by giving them some offset off their original center.

Insert this line just before the rotation modifier:

.offset(x: 0, y: 70)

This will first offset each leaf and then rotate it to produce this nice flower-like layout:

Great! This gives you a nice base to work off and create a beautiful animation. If you haven’t done that so far, it’s time you switch to the interactive preview in your Editor Canvas, by clicking the play button next to the preview.

Creating the Basic Spinning Animation

To get an animation going, you will use a timer so you can repeatedly make changes to your state over time. Each time the timer fires you will animate a different leaf, creating a wave-like effect that goes round and round.

In your SpinnerView add a new state property:

@State var currentIndex: Int?

This is the property you will be updating continuously from your timer’s callback. Move down to the empty animate() method and start the animation code by creating a new timer:

Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { timer in

})

You create a new Timer instance and configure it to fire each 0.15 seconds — this an arbitrary value I chose for that animation that you can adjust to your taste later on, if you wish.

Now, it’s time to set the current leaf index from within the timer callback block. Insert this code into the closure you just added:

if let current = self.currentIndex {
  self.currentIndex = (current + 1) % self.leavesCount
} else {
  self.currentIndex = 0
}

This code will increment the value of currentIndex each time the timer fires and reset the value back to 0 when you reach the total number of leaves. This will allow you to iterate over each of the leaves repeatedly.

Next, you need to inject that state into the Leaf view so it can render itself differently depending on whether it’s the one to currently animate.

Add this new property to Leaf:

let isCurrent: Bool

As this modifies the automatically generated Leaf initalizer you also need to update the code where you create your leaf views. Find the place inside ForEach where you create each leaf and adjust the code to also include the isCurrent property like so:

Leaf(rotation: Angle(degrees:
  (Double(index) / Double(self.leavesCount)) * 360.0),
  isCurrent: index == self.currentIndex
)

Now, you can adjust the stroke(_:lineWidth:) modifier on Capsule() to draw the leaf differently in case it should animate. Change the line .stroke(_:lineWidth:) to:

.stroke(isCurrent ? Color.white : Color.gray, lineWidth: 8)

This should get your basic animation going:

See how the current leaf “goes around” and keeps animating indefinitely.

However, this “animation” is not being animated by SwiftUI. It’s being driven by your timer. Since the state changes occur pretty quickly (between 6 and 7 times per second) they look sort-of animated, but you can do better!

Next, you’re going to invite SwiftUI to the party as well. Since you’d like to animate the changes to each separate leaf add a new modifier to the Leaf.body code (e.g. right after the rotationEffect(_) line you added there earlier):

.animation(.easeInOut(duration: 0.5), value: isCurrent)

You intentionally set an animation duration longer than the timer interval in order to have at least a few leaves animating on screen at the same time.

Unlike before, now you can see a very fluid and clean crossfade effect on each animating leaf:

If I have to be completely honest — this spinner animation is already quite pleasant. I mean, I’d take that spinner in my app any day!

But you have to believe that you can do even better! Let’s keep iterating over the current result and you can make this spinner view truly stunning.

First, adjust the duration of the animation to 1.5 seconds like so:

.animation(.easeInOut(duration: 1.5), value: isCurrent)

This will make the fade effect much more subtle than before, but that’s okay because you will add few more effects and you wouldn’t like any of them to dominate the composite animation. Find the line where you add an offset(x:y:) modifier to the capsule shape and adjust it so that the current leaf will animate slightly towards the spinner center:

.offset(x: isCurrent ? 10 : 0, y: isCurrent ? 40 : 70)

This change will make the leaves float slightly during the animation much like a sea anemone’s tentacles float in the water:

To finish off the current part of the animation, you’ll scale the leaves as they move as well.

Insert a new scale effect modifier just before the rotationEffect(_) one:

.scaleEffect(isCurrent ? 0.5 : 1.0)

This will make the leaves flow even more:

With that last change your basic animation, driven by a timer with the help of SwiftUI, is completed! Next, you will look into how to add multiple separate stages to that animation.

Adding Multiple Animation Stages

I bet you still 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 section, you will make your spinner assemble whenever it has completed its purpose on screen. For example, the network operation that the spinner represents visually has completed. You will add one more state property to distinguish between being in an “active” animating state and being in the state of completion.

Add a new state property to the SpinnerView:

@State var completed = false

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

For the purpose of this chapter, you will simply change to “completed” state after the timer has fired 60 times. This will make for a few seconds of spinning before completing.

Scroll down to animate() and insert this line at the beginning of the method:

var iteration = 0

You will use iteration to keep track of how many times the timer has fired so far and check it’s value to determine when to switch to completed state.

Next, add this, still inside the closure, at the end of the timer callback closure:

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

Resume the interactive preview in the Editor Canvas. You will see 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 little hole in there at the bottom of the shape. 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 will make all leaves perform a custom transition when you switch them to completed state. To achieve that first you will need to inject the completed state into the Leaf type.

Add to Leaf:

let isCompleting: Bool

And again, just like before, you need to update the place in the code where you create leaves, since the automatically generated initializer has been changed to include an isCompleting parameter.

Adjust your code to include that new parameter as well:

Leaf(
  rotation: Angle(degrees:
  (Double(index) / Double(self.leavesCount)) * 360.0),
  isCurrent: index == self.currentIndex,
  isCompleting: self.completed
)

OK, now that your leaf code 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 completed state. That will animate all leaves back to the initial leaf’s location. Modify the rotation modifier on the Capsule instance to:

.rotationEffect(isCompleting ? .zero : rotation)

The animation so far is driven by changes to isCurrent so when you modify isCompleting that doesn’t trigger an animation. Fret not — add one more modifier to instruct the view to animate also changes to isCompleting. Just below the existing animation(_:value:) modifier in Leaf add:

.animation(.easeInOut(duration: 1.5), value: isCompleting)

Wait until the animation plays out and you will 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 do this by setting the same width and height for the capsule which will effectively morph it into a circle. Change the frame(width:height:) modifier to:

.frame(width: 20, height: isCompleting ? 20 : 50)

If you play through the animation few times you will notice 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 completed state. Since it’s the only white leaf it causes a bit of a glitch towards the end of the completion effect. Fixing that isn’t all that difficult, you have the correct state after all - you just need to derive your UI correctly from it. In the timer closure, after you set self.completed = true, add the following:

self.currentIndex = nil

This means none of the leaves are current, so 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.

Adding 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 which deal with the situations when you add or remove a view to/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 view hierarchy.

In this section, once your spinner animation completes, you will remove the spinner view from the view hierarchy and add a transition to animate its way out of the screen. To achieve this, you will add one more state property to keep track of the spinner’s presence in the view hierarchy.

Add the following to SpinnerView:

@State var isVisible = true

While isVisible is set to true, which is your initial state, you will render the spinner on screen. Once you set isVisible to false, you will remove the view and that will trigger the “exit” transition animation.

Find the ZStack type inside the body property of SpinnerView and wrap the call and its modifier inside an if statement. Your code should now look like this:

if isVisible {
  ZStack {
    ForEach(0..leavesCount) { index in
	  ... 
	}
  }.onAppear(perform: animate)
}

This way, when you toggle the isVisible property, you will remove the spinner from the body of SpinnerView. The best place to toggle isVisible is from within the final timer callback. Scroll a bit down and find the line where you set your state to completed: self.completed = true. Since the logic here gets a bit more comple, let’s extract the code in a separate method.

First, add the following method to SpinnerView:

func complete() {
  guard !completed else { return }

  completed = true
  currentIndex = nil
  delay(seconds: 2) {
    withAnimation {
	  self.isVisible = false
	}
  }
}

Next, from within the timer closure, replace the lines where you set completed and currentIndex with:

self.complete()

When you replay the animation you will see all leaves fold up and finally disappear when you remove the stack from the view hierarchy.

Next, you will 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 which is a type erased transition. Just like with Animation, you can use a few predefined transitions and some handy factory methods to create your own:

  • AnyTransition.opacity: A cross fade effect which either fades in or out of the view.
  • AnyTransition.slide: A transition that slides in views from the left when added, and to the right side when removed.
  • AnyTransition.move(edge:): Creates a transition that moves a view in and out towards the given edge of the screen.
  • AnyTransition.offset(_): Creates a transition which moves the view by a given offset.
  • AnyTransition.scale(scale:anchor:): Creates a transition which 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.

Give one of those transitions a try. Add the following modifier to the ZStack in your SpinnerView:

.transition(.move(edge: .top))

Now, when you replay the animation, instead of disappearing in place, the spinner should first move up a little bit, and then fade out.

This move transition could be better. It is only moving the collapsed leaves to the top edge of the spinner itself, which is only the size of a single leaf. The spinner is the center piece of the screen and it would be much nicer if it transitioned out of the screen bounds instead. This is the perfect opportunity to give creating custom transitions a try.

Add a new property to SpinnerView:

let shootUp = AnyTransition
  .offset(CGSize(width: 0, height: -1000))

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

To give your custom transition a more interesting timing curve, which will make the animation start slower and speed up towards the edge of the screen, you can combine the offset(_) transition with an animation using a .easeIn timing curve.

Append:

.animation(.easeIn(duration: 1.0))

The last step you need to make in order to see the new transition on screen is to replace .transition(.move(edge: .top)) with:

.transition(shootUp)

With that, the third and last stage of your spinner animation is finished:

Take a moment to enjoy the amazing result you achieved by writing some 50 or so enjoyable lines of code!

Interactive Animations

At this point, you know plenty about creating animations with SwiftUI but there is one last topic you will cover in this chapter: Creating beautiful animations driven by the user.

You will add a drag gesture to the spinner control to give the user the ability to cancel the operation that the spinner represents — i.e. if the spinner shows up while a network request is taking place, swiping down on the spinner will cancel the network operation and hide the spinner view.

First of all, add a new state property to SpinnerView to keep track of the user’s drag gesture translation whenever a gesture is in progress:

@State var currentOffset = CGSize.zero

Now, add a gesture(_) modifier to your ZStack:

.gesture(
  DragGesture()
    .onChanged { gesture in
      self.currentOffset = gesture.translation
    }
    .onEnded { _ in
      if self.currentOffset.height > 150 {
        self.complete()
      }
      self.currentOffset = .zero
    }
)

Using the gesture(_) modifier, you add a new gesture to the view by instantiating DragGesture. Alongside the initialization, you add an onChanged(_) handler to react to changes in the position of the drag gesture and onEnded(_) to handle the end of a drag.

During the gesture, you constantly update currentOffset with the current gesture translation and, in onEnded(_), you reset that translation which will animate the spinner back to its original place.

In case the user swiped more than 150 points down, you call complete() to switch the spinner to its completed state.

The code is so simple that you actually create everything in-place and don’t bother with any local variables or helper functions.

To wrap up add three more modifiers on your ZStack which will apply effects to the view during a drag gesture:

.offset(currentOffset)
.blur(radius: currentOffset == .zero ? 0 : 10)
.animation(.easeInOut(duration: 1.0), value: currentOffset)

You apply the current drag offset to the view position and additionally, once the user starts a drag, you add a blur to the spinner for even more visual feedback.

Play the animation one last time and try dragging the spinner around and then releasing it:

If you dragged upwards or sideways, the spinner animates back to its original place. In case you dragged it down and for more than 150 points, that kicks off the completion stage animation right away.

With that last addition of a gesture driven, interactive animation, your tour of SwiftUI is complete!

Key Points

  • Shapes in SwiftUI adhere to the View protocol so you can build intriguing animations including transforming, fading in and out and morphing shapes just like you do for any other animation in your UI.
  • You can add more state to your view types to be able to drive more complex, potentially multi-stage animations.
  • Incorporating gesture input in your view state is a matter of adding few more modifiers to your views so building user interactive animations is a piece of cake.

Where to Go From Here?

So far in this book, you learned how to create basic and spring animations with SwiftUI. You’ve built quite complex visual effects that span over different stages, and that are also driven by the user. You’re on a good path to building engaging user interfaces with SwiftUI.

If you’d like to learn Apple’s SwiftUI framework in depth, the best resource to look into is the “SwiftUI by Tutorials” book on raywenderlich.com: SwiftUI by Tutorials.

As mentioned in this section’s introduction, SwiftUI is only available on iOS13, and newer. If your business case demands compatibility with iOS12, or older, you will still need to use Auto Layout and Core Animation to build your app’s UI and visual effects.

Well, good news everyone! The rest of this book is designed to turn you into a master of animations with those technologies — just turn the page over and get started!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.