19.
Animations
Written by Bill Morefield
The difference between a good app and a great app often comes from the little details. Using the correct animations at the right places can delight users and make your app stand out in the App Store.
Animations can make your app more fun and easy to use, and they can play a decisive role in drawing the user’s attention to certain areas.
Animation in SwiftUI is much simpler than animation in AppKit or UIKit. SwiftUI animations are higher-level abstractions that handle all the tedious work for you. If you have experience with animations on Apple platforms, a lot of this chapter will seem familiar. You’ll find it a lot less effort to produce animations in your app. You can combine or overlap animations and interrupt them without care. Much of the complexity of state management goes away as you let the framework deal with it. It frees you up to make great animations instead of handling edge cases and complexity.
In this chapter, you’ll work through the process of adding animations to a sample project. Time to get the screen moving!
Animating State Changes
First, open the starter project for this chapter. Build and run the project for this chapter. You’ll see an app that shows flight information for an airport. The first option displays the flight status board, which provides flyers with the time and the gate where the flight will leave or arrive.
Note: Unfortunately, showing animations with static images in a book is challenging. Sometimes, you will see pictures with arrows reflecting the expected motion. You will need to work through this chapter using the preview, the simulator or a device for the best idea of how the animations work. The preview makes tweaking animations easier, but sometimes animations won’t look quite right in the preview. Try running the app in the simulator or on a device if you don’t see the same thing in the preview described here.
Adding Animation
To start, open FlightInfoPanel.swift in the FlightDetails group and look for the following code:
if showTerminal {
FlightTerminalMap(flight: flight)
}
This code toggles showing the terminal map based on a state variable, showTerminal. The following code just before the conditional creates a button toggling the variable:
Button {
showTerminal.toggle()
} label: {
HStack {
Text(showTerminal ? "Hide Terminal Map" : "Show Terminal Map")
Image(systemName: "airplane.circle")
.imageScale(.large)
.padding(10)
.rotationEffect(.degrees(showTerminal ? 90 : -90))
}
}
This section of code also uses the state change to define the look of the button. The text changes to reflect the action the next tap will cause. You also change the rotationEffect angle between two values based on the state of the showTerminal variable.
Run the app, and you’ll see the rotation flips between the two states.
Note: If you have trouble seeing animations or the differences between animation, you can turn on Debug ▸ Slow Animations to reduce the animation speed significantly. Be sure to turn it off when you have finished.
You’ll first add an animation to this rotation. In SwiftUI, you simply provide the type of animation and let SwiftUI handle the interpolation for you. After the .rotationEffect(_:anchor:) modifier add the following code:
.animation(.linear(duration: 1.0), value: showTerminal)
In addition to the type of animation, you specify the value whose change triggers the animation. In earlier versions of SwiftUI, you didn’t need to provide this parameter as SwiftUI would determine it. That made it very easy to apply animation effects accidentally. The previous call still works but became deprecated in SwiftUI 3.0, meaning support will go away in a future release. New code should provide this value, and you should update any older code to include it. Be sure to test your app afterward, as you may have relied on the previous behavior.
Run the app, tap the Flight Status button, and tap any flight on the list. You’ll see the terminal map hidden by default. Tap on the text or airplane icon to show the map. You will see the icon slowly rotates between the up and down positions as you toggle the view instead of the nearly instant change from before.
The rotation from -90 to 90 degrees acts as a state change, and you’ve told SwiftUI to animate this state change by adding the .animation(_:value:) modifier. The animation only applies to the Image element’s rotation and no other views on the page and only activates when showTerminal changes.
Because SwiftUI iterates between the values when animating, the angles matter when you create an animation. You could specify the second angle as 270 degrees since both provide a half rotation from 90 degrees. Change the second angle of the rotation from -90 to 270. Now preview and tap the button.
You will see chevron rotates in the opposite direction from before. Positive angle changes rotate clockwise around the origin, while negative changes rotate counterclockwise. Earlier, the chevron turned clockwise when moving from upward to pointing downward. Now it rotates counterclockwise from 270 to 90 degrees.
You’re not limited to the angle of rotations of the 0 - 360 degrees range of a single rotation. Change the 270 to 630 (270 plus a 360 full rotation). Try the app now, and you’ll see that it rotates a full time and half before stopping. Notice that the rotation lasts for the same amount of time and speeds up to compensate.
Exercise: Try other angles for both the starting and ending angle to observe how different angles affect the animation and positions.
Before continuing, change the rotation to:
.rotationEffect(.degrees(showTerminal ? 90 : 270))
Animation Types
So far, you’ve worked with a single type of animation: the linear animation. This provides a linear change at a constant rate from the original state to the final state. If you graphed the change vertically against time horizontally, the transition would look like this:
SwiftUI provides several more animation types. The differences can be subtle and hard to see, which is why you stretched the animation out to a second. Not all animation types accept a parameter for the length directly, but you’ll learn other ways to adjust it.
You’ll add some code to help you see the differences in animations. Between the start of the HStack and the Text view inside the button, add the following code:
Image(systemName: "airplane.circle")
.imageScale(.large)
.padding(10)
.rotationEffect(.degrees(showTerminal ? 90 : 270))
.animation(.linear(duration: 1.0), value: showTerminal)
Spacer()
This change adds a second icon with the text centered between them. This addition, taking half the previous animation time, will help you compare animations in the rest of this section.
For the second icon, change the animation modifier to:
.animation(.default.speed(0.33), value: showTerminal)
You’ll notice the addition of the speed(_:) method. This method is one of several that you can apply to any animation. It adjusts the animation’s speed, in this case slowing it down since the value is less than one. If you use a value greater than one, the animation speed will increase.
Run the app and go to the details for a flight. While not identical, you’ll see the animations run at similar speeds. Without changing the speed, the rotation on the right plane would complete three times as quickly.
The default animation is a type of eased animation referred to as easeInOut. This animation looks good in almost all cases, so it’s a good choice if you have no other strong preference. You’ll examine the different eased animations in the next section.
Eased Animations
Eased animations might be the most common in apps. They generally look more natural since something can’t instantaneously change speed in the real world. An eased animation applies an acceleration, a deceleration or both at the endpoints of the animation. The animation reflects the acceleration or deceleration of real-world movement.
The default animation you just used is the equivalent of the easeInOut type. This animation applies acceleration at the beginning and deceleration at the end of the animation.
If you graphed the movement in this animation against time, this animation looks like this:
You can get more control using it directly. Change the animation on the second icon to:
.animation(.easeInOut(duration: 1.0), value: showTerminal)
Eased animations have a short default time of 0.35 seconds. You can specify a different length with the duration: parameter. You’ve used that to set the duration the same as the linear animation of the other icon.
Run the app, and you’ll see the two buttons take the same time to animate. The non-linear movement of the second should also be noticeable.
Now change the animation for the second icon to:
.animation(.easeOut(duration: 1.0), value: showTerminal)
Run the app and toggle the terminal map. You’ll see the rotation starts quickly and slows down shortly before coming to a stop.
Graphing the movement in this animation against time would look like this:
In addition to easeOut, you can also specify easeIn, which starts slowly at the start of the animation then accelerates.
If you need fine control over the animation curve’s shape, you can use the timingCurve(_:_:_:_) method. SwiftUI uses a bézier curve for easing animations. This method will let you define the control points for that curve in a range of 0…1. The shape of the curve will reflect the specified control points.
Exercise: Try the various eased animations and observe the results. In particular, see what different control points do in the
timingCurve(_:_:_:_)animation type.
Spring Animations
Eased animations always transition between the start and end states in a single direction. They also never pass either end state. The other SwiftUI animations category lets you add a bit of bounce at the end of the state change. The physical model for this type of animation gives it the name: a spring.
Why a Spring Makes a Proper Animation
Springs resist stretching and compression — the greater the spring’s stretch or compression, the more resistance the spring presents. Imagine a weight attached at one end of a spring. Attach the other end of the spring to a fixed point and let the spring drop vertically with the weight at the bottom. It will bounce several times before coming to a stop.
In the real world, friction and other outside forces ensure that the system loses energy each time through the cycle. This reduction makes the system damped. These accumulated losses add up, and eventually, the weight will stop motionless at the equilibrium point.
The graph of this movement looks more like this:
Creating Spring Animations
Change the animation for the second icon to:
.animation(
.interpolatingSpring(
mass: 1,
stiffness: 100,
damping: 10,
initialVelocity: 0
),
value: showTerminal
)
Run the app, and you’ll see the icon now bounces a bit at the end, going past the end and back a few times before stopping at the final position. You’ll see the icon continues a bit past the destination, slides back and then bounces around the final position a bit before stopping.
The parameters you pass are the same mentioned above:
-
mass: Controls how long the system “bounces”. -
stiffness: Controls the speed of the initial movement. -
damping: Controls how fast the system slows down and stops. -
initialVelocity: Gives an extra initial motion.
Exercise: Before continuing, see if you can determine how changes to the parameters affect the animation.
Hint: Experiment with one element at a time. First, double a value and then halve it from the original value. Use the first icon to compare two animations with a single changed parameter.
Increasing the mass causes the animation to last longer and bounce further on each side of the endpoint. A smaller mass stops faster and moves less past the endpoints on each bounce. Increasing the stiffness causes each bounce to move further past the endpoints, but less affects the animation’s length. Increasing the damping smoothes and ends it faster. Increasing the initialVelocity causes the animation to bounce further. A negative initialVelocity can move the animation in the opposite direction until it overcomes the initial velocity.
Unless you’re a physicist, the animation’s physical model doesn’t intuitively map to the results. SwiftUI introduces a more intuitive way to define a spring animation. The underlying model doesn’t change, but you can specify parameters to the model better related to how you want the animation to appear in your app. Change your animation to:
.animation(
.spring(
response: 0.55,
dampingFraction: 0.45,
blendDuration: 0
),
value: showTerminal
)
The dampingFraction controls how fast the “springiness” stops. A value of zero will never stop (try it and see). A value of one or greater will cause the system to stop without oscillation. This overdamped state will look similar to the eased animations of the previous section.
You usually use a value between zero and one, which will result in some oscillation before the animation ends. Greater values slow down faster.
The response parameter defines the system’s time to complete a single oscillation with the dampingFraction set to zero. It allows you to tune the length of time of the animation.
The blendDuration parameter provides a control for blending the length of the transition among different animations. It only comes into use if you change the parameters during animation or combine multiple spring animations. A zero value turns off blending.
Again, try varying these parameters and compare the animations produced.
Removing and Combining Animations
A common problem in the initial release of SwiftUI arose in that animations could sometimes occur where you didn’t want them. Adding the value parameter to the animation(_:value:) addresses much of this problem. There still may be times that you may want to apply no animation. You do this by passing a nil animation type to the animation(_:value:) method.
Still in FlightInfoPanel.swift add the following extra modifier after the .rotationEffect modifier:
.scaleEffect(showTerminal ? 1.5 : 1.0)
This change adds a scaling of 1.5 times the icon’s original size when showing the terminal map. If you view the animation, you will see that the button grows in sync with the rotation. An animation affects all state changes that occur on the element where you apply the animation.
Next, add the following code between the rotationEffect() and scaleEffect() methods:
.animation(nil, value: showTerminal)
Trigger the animation again. You should again see the almost instant fade-out/fade-in effect on the rotation on the icon, but the size change still shows a spring animation. You should think of an animation affecting all state changes attached to it.
You can combine different animations by using .animation(_:value:) multiple times. Change the animation on the rotationEffect() from nil to:
.animation(.linear(duration: 1), value: showTerminal)
Run the app, and you’ll see the two animations take place simultaneously, but each affects a different state change. The linear animation affects the rotation, while the spring affects the scaling of the icon. Also, note that SwiftUI handles the animations’ different lengths cleanly.
Animating From State Changes
To this point in the chapter, you’ve applied animations at the view element that changed. You can also apply the animation where the state change occurs. When doing so, the animation applies to all changes that occur because of the state change.
Remove all .animation(_:value:) modifiers from the two images. Change the action of the button that toggles showing the terminal map to:
withAnimation(
.spring(
response: 0.55,
dampingFraction: 0.45,
blendDuration: 0
)
) {
showTerminal.toggle()
}
You wrap the state change to showTerminal inside a withAnimation(_:_:) method. This call uses a spring animation, but you could pass any animation to this function. Run the app, and you’ll see the two images run the same animation in sync.
Using withAnimation(_:_:) applies the animation to every visual change that results from the state change in the closure. This method simplifies the code when you wish to use a single animation to multiple changes resulting from a state change. Be careful as SwiftUI applied the animation for all state changes, including implicit ones caused by the change. In this example, if another property relied on showTerminal value, the animation would also apply to that property.
Now that you understand the basics of animation in SwiftUI, you’ll apply animation to other parts of the app.
Animating Shapes
Open TerminalStoresView.swift in the FlightDetails group. This view displays the stores in a terminal that you created in Chapter 18: “Drawing & Custom Graphics”. In this section, you’ll add some animation to these shapes when they appear. First, add a state variable to the struct below the flight property.
@State private var showStores = 0.0
Now find the declaration of xOffset inside the ForEach loop and change it to:
let xOffset =
Double(index) * storeSpacing * direction * showStores + firstStoreOffset
You added a multiplication by the showStores state property. Recall from the previous chapter that the rest of this calculation determines the horizontal position of the store. By setting showStores to zero, the stores will all appear at the firstStoreOffset. By setting showStores to one, you get the previous location. Changing the value of a variable like this creates a state change you can animate.
Now add the following code after the offset(x:y:) modifier within the ForEach loop:
.animation(.easeOut, value: showStores)
You apply a default ease-out animation when showStores changes. You need to trigger the state change to start the animation. Here you’ll activate it when the view appears. At the end of the GeometryReader add the following code:
.onAppear {
showStores = 1.0
}
Code inside onAppear(perform:) executes when the attached view appears on the device. Here you change the value of showStores to 1.0, which will change the offset.
Run the app, tap Search Flights and choose any flight and tap Show Terminal Map. You’ll see the map appear and the store shapes slide into place.
If you hide and show the terminal map several times, you’ll notice the animation does not repeat. That’s because it takes a short period before SwiftUI destroys a removed view. If you show the view before SwiftUI destroys it, SwiftUI will reuse the existing view and not call onAppear(perform:) again.
In the next section, you’ll look at you’ll explore the delay() method in animations.
Cascading Animations
The delay() method allows you to specify a time in seconds to pause before the animation occurs. You can also use it to allow animations to chain together and provide a sense of progress or motion.
Open TerminalStoresView.swift and change showStores to:
@State private var showStores = false
You change showBars to a boolean. Change the definition of xOffset inside the ForEach loop back to:
let xOffset = Double(index) * storeSpacing * direction + firstStoreOffset
This code removes the state change from the calculation. Instead, you’ll change the state directly within the offset(x:y:) modifier. Change the offset(x:y:) modifier to:
Now change the RoundedRectangle offset to:
.offset(
x: showStores ?
xOffset :
firstStoreOffset - direction * width,
y: height * 0.4
)
You also need to change the code inside onAppear(perform:) so it reads:
.onAppear {
showStores = true
}
Now you can add the delay to the animation. Change the animation after the offset on the Rectangle to:
.animation(.easeOut.delay(Double(index) * 0.3), value: showStores)
You apply a different animation to each iteration through the ForEach loop. This code now uses the same index property used to set the position of the store. For each greater index, you delay the animation by 0.3 seconds.
Now run the app. You’ll see the result of the delayed animations as the stores snap into place one at a time.
Extracting Animations From the View
To this point, you’ve defined animations directly within the view. For exploring and learning, that works well. Maintaining code in real apps is easier when you keep different sections of your code separate. In TerminalStoresView.swift, add the following code above the body structure:
func storeAnimation(_ storeNumber: Int) -> Animation {
return .easeInOut.delay(Double(storeNumber) * 0.3)
}
You define a custom animation method to return an Animation structure. This one contains the same animation as before. Now replace the animation(_:value:) in the view with:
.animation(storeAnimation(index), value: showStores)
Run the app and confirm the animation did not change. You could reuse this animation elsewhere in the view and only change it in one place. For more complex animations, extracting the animation also improves the readability of your code.
Next, you’ll implement a more complex animation adding a visual indicator to the terminal map.
Animating Paths
Open GatePathView.swift in the FlightDetails group, and you’ll see a view that draws a line determined using a set of fixed points scaled to the view’s size. The code below draws the path:
Path { path in
// 1
let walkingPath = gatePath(proxy)
// 2
guard walkingPath.count > 1 else { return }
// 3
path.addLines(walkingPath)
}
.stroke(lineWidth: 3.0)
If you need a review of Path and GeometryReader, see Chapter 18: “Drawing & Custom Graphics”. Here’s what this code does:
- The
gatePath(_:)method returns an array ofCGPoints scaled to the current view using theGeometryProxy. - This check ensures there are at least two points in the array — the minimum for a line — and if not, it returns an empty path.
- The
addLines(_:)method expects an array of points. It moves the path to the first point in the array and then adds lines connecting the remaining points.
This view will provide a line to the gate when drawn on top of the terminal map. Go to TerminalMapView.swift and add the following code after TerminalStoresView:
GatePathView(flight: flight)
.foregroundColor(.white)
Run the app, tap Flight Status and then tap any flight to see the new path to the gate drawn in white over the map.
In the next section, you’ll animate this path.
Making a Path State Change
To animate this path, you need a state change on a property SwiftUI knows how to animate. A SwiftUI Shape has a method trim(from:to:) that trims a shape to a fractional portion based on its representation as a path. For a shape implemented as a path, the method provides a quick way to draw only a portion of the path.
First, go to GatePathView.swift and add the following code after the current GatePathView struct:
struct WalkPath: Shape {
var points: [CGPoint]
func path(in rect: CGRect) -> Path {
return Path { path in
guard points.count > 1 else { return }
path.addLines(points)
}
}
}
This struct implements a custom Shape view that implements the same code as the view. You pass in the array of points and create the path as before. Now add a new state variable after the flight parameter at the top of the struct:
@State private var showPath = false
Then add a new animation property after the showPath property:
var walkingAnimation: Animation {
.linear(duration: 3.0)
.repeatForever(autoreverses: false)
}
This code creates a linear animation three seconds long. The repeatForever(autoreverses:) method sets the animation to repeat when it finishes. Setting autoreverses to false, means the animation restarts each time instead of rewinding backward before restarting.
Change the closure for the GeometryReader to use the new shape instead of drawing the path directly:
WalkPath(points: gatePath(proxy))
.trim(to: showPath ? 1.0 : 0.0)
.stroke(lineWidth: 3.0)
.animation(walkingAnimation, value: showPath)
The added trim(from:to:) method contains the state change. You also attach the animation to the view telling SwiftUI to animate the state change.
Finally, add the following code at the end of the view after the GeometryReader:
.onAppear {
showPath = true
}
As earlier, you use the onAppear(perform:) modifier to start the animation when the view appears.
Run the app, and show any terminal map. You’ll see the line trace out the path to the gate and repeat every three seconds.
Making Canvas Animations
In Chapter 18: “Drawing & Custom Graphics”, you learned about the Canvas view meant to provide better performance for a complex drawing, mainly when it uses dynamic data. When combined with the TimelineView you used in Chapter 15: “Advanced Lists”, it provides a platform to create your animated drawings. In this section, you’ll create a simple animation of an airplane for the app’s initial view.
Create a new SwiftUI view named WelcomeAnimation. At the top of the new view, add the following two properties:
private var startTime = Date()
private let animationLength = 5.0
The startTime property will hold the time the view appears and will be used to determine how long the animation runs. The animationLength property will determine how long it takes for the animation to complete.
Next, replace the current body of the view with a TimelineView:
TimelineView(.animation) { timelineContext in
}
You specify the .animation schedule asking SwiftUI to update as fast as possible. Inside the TimelineView closure, add the following code:
Canvas { graphicContext, size in
// 1
let timePosition = (timelineContext.date.timeIntervalSince(startTime))
.truncatingRemainder(dividingBy: animationLength)
// 2
let xPosition = timePosition / animationLength * size.width
// 3
graphicContext.draw(
Text("*"),
at: .init(x: xPosition, y: size.height / 2.0)
)
} // Extension Point
The Canvas view expands to fill its parent view. You use the graphicContext for drawing, and the size parameter gives you the dimensions of the drawing space. Then you’ll do some calculations to perform the animation.
- You first get the difference in seconds between the date from the
timelineContextparameter to the closure and the time when the view loaded in thestartTimeproperty. You use thetruncatingRemainder(dividingBy:)method on the resulting Double to constrain this value of the range from zero to theanimationLengthproperty for the view. When the value reachesanimationLength, it will wrap around to zero. - You divide the value from step one by the
animationLengthproperty to get the fraction of the entire animation length the time represents. You multiply this fraction by the width of the canvas giving the horizontal position for this animation frame. - For now, you’ll just write an asterisk at the horizontal position from step two and the vertical position centered in the canvas.
To see what you’ve done to this point, go back to WelcomeView.swift. Add the following code inside the NavigationSplitView before the start of the List:
WelcomeAnimation()
.frame(height: 40)
.padding()
Run the app, and you’ll see your animation works as a small asterisk that slides across the view above the navigation buttons.
The animation looks nice, but now we need to add the airplane. It seems a waste to create an airplane when SF Symbols provides a perfectly usable airplane image. Fortunately, SwiftUI allows you to bring external SwiftUI views into a canvas for use.
Go back to WelcomeAnimation.swift. Extend the current Canvas view with the following additional closure in place of the // Extension Point comment so the symbols label appears on the same line.
symbols: {
Image(systemName: "airplane")
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(height: 40)
.tag(0)
}
Make sure to begin the new code immediately after the closing brace of the closure, so the symbols parameter appears on the same line. The symbols parameter for the Canvas creates a ViewBuilder to supply SwiftUI views to the canvas. Here you provide an image view with modifiers to produce a 40 point square image. You must give each view inside the symbols closure a unique value using the tag(_:) modifier.
You can now use this passed SwiftUI view inside the Canvas. At the top of the Canvas closure, add the following line:
guard let planeSymbol = graphicContext.resolveSymbol(id: 0) else {
return
}
You use the resolveSymbol(id:) on the graphics context to access the SwiftUI views. The id here should match the id provided in the view’s tag(_:) modifier. If the symbol doesn’t exist, you return since there’s nothing to draw, resulting in an empty canvas. Now change the existing GraphicsContext.draw(_:at:anchor:) method (after comment three) to:
graphicContext.draw(
planeSymbol,
at: .init(x: xPosition, y: size.height / 2.0)
)
Instead of text, you now draw the SwiftUI view using the same draw(_:at:anchor:) method passing in the planeSymbol you obtained using resolveSymbol(id:). Run the app to see the finished animation.
Key Points
- Don’t use animations only for the sake of doing so. Have a purpose for each animation.
- Keep animations between 0.25 and 1.0 seconds in length. Shorter animations are often not noticeable. Longer animations risk annoying your user wanting to get something done.
- Keep animations consistent within an app and with platform usage.
- Animations should be optional. Respect accessibility settings to reduce or eliminate application animations.
- Make sure animations are smooth and flow from one state to another.
- Animations can make a huge difference in an app if used wisely.
- You can create high-performance animations by combining
TimelineViewandCanvas.
Where to Go From Here?
If you want to dive deep into creating and using animation in SwiftUI, the book SwiftUI Animations by Tutorials at https://www.kodeco.com/books/swiftui-animations-by-tutorials is dedicated to the topic.
This chapter focused on creating animations and transitions, but not why and when to use them. A good starting point for UI-related questions on Apple platforms is the Human Interface Guidelines, here: https://developer.apple.com/design/human-interface-guidelines/.
The WWDC 2018 session, Designing Fluid Interfaces, also details gestures and motion in apps. You can view it at https://developer.apple.com/videos/play/wwdc2018/803.