6 Intro to Custom Animations¶
In this book, you’ve explored many ways SwiftUI makes animation simple to achieve. By taking advantage of the framework, you created complex animations with much less effort than previous app frameworks required. For many animations, this built-in system will do everything that you need. However, as you attempt more complex animations, you’ll find places where SwiftUI can’t do what you want without further assistance.
Fortunately, the animation support in SwiftUI includes protocols and extensions that you can use to produce animation effects beyond the basics while still having SwiftUI handle some of the work. This support lets you create more complex animations while still leveraging SwiftUI’s built-in animation capabilities.
In this chapter, you’ll start by adding a standard SwiftUI animation to an app. Then you’ll learn to implement animations beyond the built-in support while having SwiftUI handle as much as possible.
Animating the Timer¶
Open the starter project for this chapter. You’ll find an app that helps users brew and prepare tea. Build and run the app and tap any of the provided teas.
The app lists several types of tea and provides suggestions on water temperature and the amount of tea leaves needed for the desired amount of water. It also provides a timer that counts down the time needed to steep the tea.
You’ll see information for brewing the tea you selected. Adjust the amount of water, and it’ll update the needed amount of tea leaves to accommodate the change. It also lets you start and stop a timer for steeping the tea. When you start the brewing timer, it begins a countdown until your steeping completes.
Once it finishes, a view tells you your tea is ready to drink.
While it works, the app lacks energy and excitement. You’ll add some animations that give it more energy and help users in their quest for the best tea.
First, the ring around the timer turns blue when you start the timer. While the color change does show the timer is running, it doesn’t attract the eye. To do so, you’ll animate the timer’s border as a better indicator of a running timer.
Open TimerView.swift, and you’ll see the code for this view. The CountingTimerView
used in this view contains the control for the timer. It currently uses overlay(alignment:content:)
to add a rounded rectangle with the color provided by the timerBorderColor
computed property. You’ll add a special case to display an animated border when the timer is running.
After the existing state properties, add the following new property:
@State var animateTimer = false
You’ll use this property to control and trigger the animation by toggling its value. The animation here will animate the border around the timer display and controls. You’ll animate the border so it appears to move in a circle around the digits and controls. To do this, you’ll create an angular gradient or conic gradient.
Unlike the more common linear gradient, which blends colors based on the distance from a starting point, an angular gradient blends colors as it sweeps around a central point. Instead of the distance from the starting point determining the color, the angle from the central point determines the color. All points along a line radiating from the center will share the same color.
Add the following code after the existing computed properties to create the angular gradient:
var animationGradient: AngularGradient {
AngularGradient(
colors: [
Color("BlackRussian"), Color("DarkOliveGreen"), Color("OliveGreen"),
Color("DarkOliveGreen"), Color("BlackRussian")
],
center: .center,
angle: .degrees(animateTimer ? 360 : 0)
)
}
You specify the gradient will begin as a dark shade of black, transition to olive green at the midpoint, and then back to the same shade of black at the end. You set the gradient to use the center of the view as its origin. To allow animation, you set the angle by multiplying animateTimer
by 360 degrees.
Toggling animaterTimer
to true
will rotate the gradient in a complete revolution. Note that the gradient will transition through a complete circle since you only specify a single angle. SwiftUI positions the start of the gradient at that angle and sweeps through the full rotation to the final color. It’ll provide a circular shift from nearly black through olive green around the circle and then back to nearly black, where the gradient started.
Now find the overlay
modifier on CountingTimerView
and replace its contents with:
switch timerManager.status {
case .running:
RoundedRectangle(cornerRadius: 20)
.stroke(animationGradient, lineWidth: 10)
default:
RoundedRectangle(cornerRadius: 20)
.stroke(timerBorderColor, lineWidth: 5)
}
While the timer runs, you apply a different style to stroke(_:lineWidth:)
that uses the gradient you just added. You also widen the line to draw the eye and provide more space for the animation to show, and add another visual indicator that something has changed.
Now, build and run the app. Tap any tea and then start the timer. The border takes on the new broader gradient but doesn’t animate yet. You’ll do that in the next section.
Animating the Gradient¶
Still in TimerView.swift, find onChange(of:perform:)
on NavigationStack
. This modifier monitors changes to the timer’s status
. Currently, it only checks for the .done
state on the timer. Add a new case
to the existing switch statement:
case .running:
// 1
withAnimation(
.linear(duration: 1.0)
// 2
.repeatForever(autoreverses: false)
) {
// 3
animateTimer = true
}
Here’s what you did:
- You create an explicit animation that produces a one-second linear animation. Using a linear animation produces constant motion that matches the flow of time. Setting the length to one second matches the rate at which the numbers in the timer change. Keeping the animation in sync with the number changes helps visually tie the two together.
- By default, the animation only occurs once when the state changes. You could do a continuous change of
animateTimer
, perhaps by tying it directly to the elapsed time. Still, there’s an easier way.repeatForever(autoreverses:)
tells SwiftUI to restart the animation when it completes. By default, the animation would reverse before repeating. You passfalse
toautoreverses
to skip the reversing of the animation. - You change
animateTimer
to true. Since this occurs in the closure, it’ll animate the state change using the specified animation. The state changes cause the angular gradient to rotate one complete revolution, which will be animated.
Run your app, select any tea and start the timer. You’ll see the gradient rotates while the timer counts down.
Next, you’ll look at a similar animation using opacity to produce a pulsing effect when the user pauses the timer.
Animating the Pause State¶
You’ll also add an animation when the user pauses the time. First, add the following state property after the existing ones:
@State var animatePause = false
You’ll change this state property to trigger the animation when the user pauses the timer.
Now find overlay(alignment:content:)
on CountingTimerView
and add a new case for the .paused
state:
case .paused:
RoundedRectangle(cornerRadius: 20)
.stroke(.blue, lineWidth: 10)
.opacity(animatePause ? 0.2 : 1.0)
You added a new option for the case when the timer reaches the paused
state. As with the others, you apply a stroke(_:lineWidth:)
, in this case, a blue line the same width as when running. You then apply opacity(_:)
using animatePause
to change it between 0.2 (almost transparent) to 1.0 (fully opaque).
Now find the onChange(of:perform:)
modifier you worked in earlier. Add the following line right at the beginning of the .running
case:
animatePause = false
This code resets the property when the timer begins running. It’s essential to ensure the animation is ready if triggered again.
Now you need to handle the new paused state. Still in onChange(of:perform:)
, add a new case to handle the .paused
state:
case .paused:
// 1
animateTimer = false
// 2
withAnimation(
.easeInOut(duration: 0.5)
.repeatForever()
) {
animatePause = true
}
And replace the break
in the default
case with:
// 3
animateTimer = false
animatePause = false
Here’s what this code does:
- When your time switches to the
paused
state, you setanimateTimer
to false. SettinganimateTimer
back to its original state prepares it if the user starts the timer again. - You use an explicit animation when setting
animatePause
to true. Recall this will change the opacity from 0.2 to 1.0. You apply an ease-in-out animation lasting one half-second. You also applyrepeatForever(autoreverses:)
using the default parameter forautoreverses
, which will reverse the animation before repeating it. As a result, the animation will cycle from dim to bright and back once per second. - If the timer status changes to any other state, then neither animation should be active, and you set both state properties to false.
Run your app, select any tea and start the timer. After a few seconds, pause the timer. You’ll see the border of the timer pulse.
These two animations work like many others you’ve seen, taking advantage of SwiftUI automatically handling the animation for a Bool
value such as animateTimer
. In the next section, you’ll learn how to handle more complex cases when SwiftUI can’t handle the animation for you. It’s time to look into the Animatable
protocol.
Making a View Animatable¶
As mentioned in Chapter 1: Introducing SwiftUI Animations, an animation is a series of static images changing rapidly and providing the illusion of motion. When SwiftUI animates a shape, it rapidly draws the view many times. The type of animation and the elements that change determine how the views change. In the previous section, you changed the angle of the angular gradient and SwiftUI animated the result of that change.
SwiftUI can’t manage a change to a Path
or a shift in the text shown in a Text
view. In these cases, you can conform to the Animatable
protocol and manage the animation yourself.
In this section, you’ll use Animatable
to implement a text view that can animate the transition between two numbers. In these cases, you’ll turn to the underlying structure you’ve been using and directly implement what you need.
Behind the scenes of every SwiftUI animation lies the Animatable
protocol. You turn to it when you can’t do what you want with just animation(_:)
or withAnimation(_:_:)
.
This protocol has a single requirement, a computed property named animatableData
which must conform to the VectorArithmetic
protocol. A value that conforms to this protocol ensures that SwiftUI can add, subtract and multiply the value. Many built-in types already support this protocol, including Double
, which you’ll use in this chapter.
These two protocols allow SwiftUI to provide a changing value to animate independent of how the view implements the animation. SwiftUI calculates the new animatableData
values based on the kind of animation used. Your view needs to handle the values that SwiftUI sends to it. It lets you produce a single view that can handle any animation without worrying about the differences between linear or spring animations.
Create a new SwiftUI View file named NumberTransitionView.swift and open it. Update the definition of the generated struct to:
struct NumberTransitionView: View, Animatable {
Adding the Animatable protocol lets you provide direct control of the animated values. Next, add the following code to the top of the struct:
var number: Int
var suffix: String
var animatableData: Double {
get { Double(number) }
set { number = Int(newValue) }
}
Here, you create a property to hold the number the view will display as an Int
. You’ll also let the user pass in a string to append to the number.
You then implement the animatableData
required by the Animatable
protocol as a computed property. This computed property gets or sets the value of number
while converting between Int
and Double
as needed. In this case, given the range of values you’ll animate, you don’t need the extra resolution provided by the double.
Update the view’s body
to:
Text(String(number) + suffix)
You display the number and append the passed suffix to the end. Finally, update the preview to provide a number and the suffix by changing it to:
NumberTransitionView(number: 5, suffix: " °F")
If you look at the preview, you won’t see much difference between this view and a regular text view showing a number.
The difference will only show when you animate the view. You’ll do that in the next section.
Using an Animatable View¶
Open BrewInfoView.swift. You’ll add a bit of animation to the brewing temperature that appears on the view. Add the following new property after the existing state properties:
@State var brewingTemp = 0
You’ll use this property to change the value displayed. Initially, you set it to zero, so you can change it when the view appears. Now attach the following modifier to the VStack
before padding(_:_:)
:
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
brewingTemp = brewTimer.temperature
}
}
You set the state property to the temperature passed into this view. You wrap this change inside an explicit call to withAnimation(_:_:)
and specify an ease-out animation that lasts one half-second.
You choose the ease-out animation because the fast initial change of this type of animation makes the interface seem speedy. The short duration also gives the user enough time to see the animation while remaining quick enough so they don’t grow impatient.
Before implementing the animation, you’ll change the view so you can better compare it to the final animation. Find the line in the view that reads Text("\(brewTimer.temperature) °F")
and change it to:
Text("\(brewingTemp) °F")
This change shows the new property instead of the brewing temperature passed into the view. So, the value will initially be zero and change to the final temperature.
Run the app and select any tea. When the view appears, you’ll see what you probably expected. The initial view showing the zero fades out, and the new view showing the desired temperature fades in. SwiftUI doesn’t know how to animate text changing from zero to a temperature, so it uses a view transition.
Change the text line to use your new view. Replace the view showing the brewing temperature with:
NumberTransitionView(number: brewingTemp, suffix: " °F")
Run the app and select any tea.
You’ll notice an immediate difference. Instead of a view transition, the number counts up from zero to the target temperature. You’ll also see the number change quickly at first before slowing as it reaches the final temperature. It gets to that final temperature after one half-second.
That’s the power of the Animatable
protocol! It lets you make almost anything you can imagine animate with SwiftUI. You take care of the state change as before and let SwiftUI calculate the changed values. In your view, you accept the changing values through the protocol and show appropriate values.
In the next section, you’ll work on a more complex scenario to produce a better animation for the timer as it counts down.
Creating a Sliding Number Animation¶
Open CountingTimerView.swift. On the first line of the VStack
, you’ll see the timer currently displays a string from timerManager
. This string shows the remaining time formatted using a DateComponentsFormatter
that shows only the minutes and seconds. The result provides the information, but it’s a bit plain.
Before digital timers, clocks often used mechanical movements that moved printed numbers to show time. In this section, you’ll create an animated version of this type of display for the steeping timer as it counts down. You’ll begin by creating a new view that shows each timer digit in a separate view.
Create a new SwiftUI View file named TimerDigitsView.swift. Add this new property to the top of the view:
var digits: [Int]
You’ll pass in the digits of the timer as an array of Int
values. The first two store the minutes, and the last two values in the array store the seconds. This change will display these values individually and make each digit easier to animate. Add this code below the digits
property:
var hasMinutes: Bool {
digits[0] != 0 || digits[1] != 0
}
This computed property will return false only when both digits of the minutes are zero. You’ll use this to help format the numbers in this view. Now change the body of the view to:
HStack {
// 1
if hasMinutes {
// 2
if digits[0] != 0 {
Text(String(digits[0]))
}
// 3
Text(String(digits[1]))
Text("m")
}
// 4
if hasMinutes || digits[2] != 0 {
Text(String(digits[2]))
}
// 5
Text(String(digits[3]))
Text("s")
}
Here’s what this view does:
- You check the computed property and see if the time contains minutes. If not, then you skip displaying information about the minutes.
- When minutes exist, you display the first digit as long as it’s not zero.
- You always show the second digit of the minutes followed by the letter m to indicate this value shows minutes.
- If the first seconds digit is zero or if you had minutes, you show the first seconds digit. This condition will display a leading zero only when the time contains minutes.
- You always show the seconds digit and an s string to indicate these show seconds.
Update the preview to pass in digits. Change the preview to:
TimerDigitsView(digits: [1, 0, 0, 4])
Open CountingTimerView.swift and look for the code that reads Text(timerManager.remaingTimeAsString)
. Replace it with:
TimerDigitsView(digits: timerManager.digits)
Now you use this new view to show the time. The digits
property of timerManager
formats an array with the desired data based on the remaining time.
Run the app, select a tea and start the timer. The view looks similar to the original, except now, each digit is a separate view instead of a single Text
view showing a formatted string.
Visually, the most noticeable difference is more spacing between the letters and numbers indicating the time.
Now you’ll build a view to animate these individual digits. Create a new SwiftUI View file named SlidingNumber.swift. Open the new view and change the definition of the struct to:
struct SlidingNumber: View, Animatable {
As a reminder, adding Animatable tells SwiftUI that you’ll make this view support animation. As before, you implement the animatableData
required by the protocol. Add this code to the top of the struct:
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
You store the value sent by SwiftUI in a Double
property named number
. You might wonder why you need a Double
here instead of the Int
you used in the last section, even though you’ll only display single integer digits.
The reason comes down to the granularity of the data. In the previous section, you produced animation between far apart integers. Here, you’ll change between adjacent digits. To create a smooth animation, you need the fractional values between the two numbers.
Update the preview to read:
SlidingNumber(number: 0)
With this view in place, you have the foundation to animate the timer. In the next section, you’ll examine how to get the desired effect.
Building an Animation¶
When developing an animation, it helps to consider the visual effect you want to achieve. Go back to the inspiration of a sliding scale of digits. You’ll implement a strip of numbers starting at nine and then moving down through zero. When the digit changes, the strip of numbers shifts to show the new value.
In SwiftUI terms, you want a vertical strip of the numbers around the new value. When the number changes, SwiftUI will provide a series of values between the original and new number through animatableData
.
Look at this example where number
begins at four and changes to three.
The series provided through animatableData
begins at four and will decrease to three though the exact values will vary depending on the type of animation. The first value is slightly below four. The fractional part of the number indicates how far you’re through the change in the digit and begins as near one and approaches zero.
As that fractional part decreases, you shift the number upward toward the new number.
Once the number reaches the new value of three, the view resets so that the central value is that new number. The cycle can then repeat when the number changes again. With that background, you can now implement it in SwiftUI in the next section.
Implementing Sliding Numbers¶
First, you need a vertical strip of numbers. Delete the existing Text
view inside the body, and add the following code at the top of the view body:
// 1
let digitArray = [number + 1, number, number - 1]
// 2
.map { Int($0).between(0, and: 10) }
This code calculates the numbers to show:
- You create an array of the number after the current number, the current number and the numbers below the current number. If
number
is four, the array would contain [5, 4, 3]. This array lets the animation flow in both directions. - You use
map
on the array to convert the values to integers and remove that fractional amount from theDouble
. You also usebetween(:and)
from IntegerExtensions.swift to handle the edge cases. The value below zero is nine, and the value above nine is zero.
Add the following code below what you just added:
let shift = number.truncatingRemainder(dividingBy: 1)
You use truncatingRemainder(dividingBy:)
to get only the fractional part of the Double
. As mentioned in the last section, this indicates how far through the animation you are. Next, add:
// 1
VStack {
Text(String(digitArray[0]))
Text(String(digitArray[1]))
Text(String(digitArray[2]))
}
// 2
.font(.largeTitle)
.fontWeight(.heavy)
// 3
.frame(width: 30, height: 40)
// 4
.offset(y: 40 * shift)
This code implements the steps discussed in the last section. Here’s how each part creates part of the animation:
- To create the strip of digits, you use a
VStack
showing the integers you stored indigitArray
. - You apply the
.largeTitle
font with a heavy weight to let the digits stand out. - You set the frame for the view to 30 points wide and 40 points tall. The height matches the distance between digits in the
VStack
. - You take the
shift
you calculated earlier as the portion of the height that the view should shift for the current point in the animation. You multiply it by 40, the distance between digits in the stack. That converts theshift
into an amount of vertical movement for the view.
Now you need to use this new view. Open TimerDigitsView.swift and change the body to:
HStack {
if hasMinutes {
if digits[0] != 0 {
SlidingNumber(number: Double(digits[0]))
}
SlidingNumber(number: Double(digits[1]))
Text("m")
}
if hasMinutes || digits[2] != 0 {
SlidingNumber(number: Double(digits[2]))
}
SlidingNumber(number: Double(digits[3]))
Text("s")
}
This code replaces the Text
views from earlier with your new SlidingNumber
view. Run the app, select any tea and start the timer.
In this state, you’ll see the entire strip of digits. As it animates, note that the strip shifts and how new numbers appear and vanish as the animation progresses.
Use the Slow Animations option in the simulator to help.
Once you watch the animation, you’ll finish cleaning up the view. Open SlidingNumber.swift. Add two more modifiers after offset(x:y:)
:
// 1
.overlay {
RoundedRectangle(cornerRadius: 5)
.stroke(lineWidth: 1)
}
// 2
.clipShape(
RoundedRectangle(cornerRadius: 5)
)
Here’s what these do:
- You give the digit a thin frame using
stroke(lineWidth:)
applied to aRoundedRectangle
. - While there is a strip of numbers, you only want to show a single number at a time. You do this using
clipShape(_:style:)
withRoundedRectangle
that matches the one used to produce the frame in step two. This shape fills the frame and clips to the frame you applied to the view. Clipping removes any elements outside that space and hides the extra digits in theVStack
.
Run the app and start a steeping timer. You’ll see only a single digit that animates as the timer changes. It also has a nice surrounding border that helps each number stand out.
Challenge¶
Using what you’ve learned in this chapter, adjust the timer animation so the digits slide in the opposite direction and the numbers slide downward. As a hint, recall that in SwiftUI, a decrease in offset will cause a shift upward. How can you make that move down instead?
Check the challenge project in the materials for this chapter for one solution.
Key Points¶
- An angular gradient shifts through colors based on the angles around a central point.
- The Animatable protocol provides a method to help you handle the animation within a view yourself. You only need to turn to it when SwiftUI can’t do things for you.
- When using the Animatable protocol, SwiftUI will provide the changing value to your view through the
animatableData
property. - When creating custom animations using the Animatable protocol, begin by visualizing what you want the finished animation to look like.
- Take advantage of SwiftUI’s ability to combine elements. In many cases, breaking an animation into smaller components will make it easier. You’ll find it easier to animate individual digits instead of trying to animate an entire display of numbers.
Where to Go From Here?¶
- You can read more about angular gradients in Apple’s Documentation.
- You can find other examples of using the Animatable protocol in Getting Started with SwiftUI Animations.
- You’ll also explore the Animatable protocol more in the next section, including learning how to deal with animations involving multiple elements.