14.
Animations
Written by Bill Morefield
The difference between a good app and a great app often comes from the little details. Using the right animations at the right places can delight users and make your app stand out in the crowded App Store.
Animations can make your app more fun to use, and they can play a powerful role in drawing the user’s attention in certain areas. Good animations make your app more appealing and easier to use.
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 in 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. This 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 shaking!
Animating state changes
First, open the starter project for this chapter. Build and run the project in XCode 11 or greater, and you’ll see an app that shows flight information for an airport. The lists provide flyers with the time and the gate where the flight will leave or arrive.
Note: Unfortunately, it’s challenging to show animations on a printed page. You’ll need to work through this chapter using preview, the simulator or on a device.
Adding animation
To start, open FlightBoardInformation.swift and look for the following code:
Button(action: {
self.showDetails.toggle()
}) {
HStack {
if showDetails {
Text("Hide Details")
Spacer()
Image(systemName: "chevron.up.square")
} else {
Text("Show Details")
Spacer()
Image(systemName: "chevron.down.square")
}
}
}
This code changes the text and image depending on whether it shows the flight details. That works, but it would look smoother if you applied an animation to the chevron. Replace this code with:
Button(action: {
self.showDetails.toggle()
}) {
HStack {
Text(showDetails ? "Hide Details" : "Show Details")
Spacer()
Image(systemName: "chevron.up.square")
.rotationEffect(.degrees(showDetails ? 0 : 180))
}
}
Again, press Play to interact with the view, and you’ll see that it functions the same as before. You’re now using a rotationEffect that changes between values based on the state of the showDetails variable. An animation occurs over the period of time to change from a starting state to an ending state. You tell SwiftUI the type of animation, and it handles the interpolation for you. Change the Image view to read:
Image(systemName: "chevron.up.square")
.rotationEffect(.degrees(showDetails ? 0 : 180))
.animation(.default)
Preview the updated view and tap the button to show flight details. You will see the chevron now rotates between the up and down positions. The rotation from zero to 180 degrees acts as a state change, and you’ve told SwiftUI to animate this state change by adding the .animation() modifier. The animation only applies to the rotation on the Image element and no other elements on the page.
The angles in the change matter when you create an animation. You could specify the second angle as -180 degrees since both provide a half rotation. Change the angle of the rotation from 180 to -180. Now preview and tap the button.
You will see chevron rotates in the opposite direction. Positive angles rotate clockwise and negative angles rotate counterclockwise. Before, the chevron rotated clockwise from pointing upward to pointing downward. Now it rotates counterclockwise from zero to -180 degrees.
You’re not limited to the angle of rotations of the 0 - 360 degrees range of a single rotation. Change the -180 to 540. Try the app now, and you’ll see that it rotates a full time and half before stopping.
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 back to:
.rotationEffect(.degrees(showDetails ? 0 : 180))
Animation types
So far, you’ve worked with a single type of animation: the default animation. SwiftUI provides more animation types. The differences can be subtle and hard to see on the small chevron. To make the changes easier to notice, you’ll add another animation to this view.
Animating the Details view
Stay in FlightBoardInformation.swift. The code to show the details for the flight looks like:
if showDetails {
FlightDetails(flight: flight)
}
If showDetails is true, then the view with details on the flight appears. If not, the view isn’t added. You can animate the transition of adding and removing views and will do so later in this chapter. For now, you can change the view to appear by shifting the view from off the screen. Change this code to:
FlightDetails(flight: flight)
.offset(x: showDetails ? 0 : -UIScreen.main.bounds.width)
This replaces the condition with an offset on the FlightDetails view. When showDetails is true, no shift occurs. When the details should not be visible, then the offset shifts UIScreen.main.bounds.width — the width of the current screen — to the left, ensuring it is not visible.
Preview the view, and you’ll see little difference for now. Again though you’ve created a state change that you can animate.
Default animation
Now, you’ll change the code to add an animation. After the offset, add:
.animation(.default)
Now in the preview, you’ll notice the flight details view slides in from the left side of your app. When hidden, it slides back off the left side.
The default animation is the simplest animation type. It 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:
Eased animations
Eased animations might be the most common in apps. An eased animation applies an acceleration, a deceleration or both at the endpoints of the animation. They generally look more natural since it’s impossible for something to instantaneously change the speed in the real world. The animation reflects the acceleration or deceleration of real-world movement.
First, change the animation for the details view to:
.animation(.easeOut)
Preview the view and when you show the flight details, you’ll see the view slides in quickly and slows down shortly before coming to a stop.
Graphing the movement in this animation against time would look like:
Eased animations have a short default time of 0.35 seconds. You can specify a different length with the duration: parameter. To do so, change the animation to:
.animation(.easeOut(duration: 2))
You will see the same animation, but it will now take two seconds to complete. You can specify a duration for any eased animation.
In addition to easeOut, you also can specify easeIn which starts slowly at the start of the animation then accelerates.
You can combine the two using the easeInOut type. This applies acceleration at the beginning and the deceleration at the end of the animation.
Graphed, it looks like this:
If you need fine control over the shape of the curve, you can use the timingCurve(_:_:_:_) type 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 category of SwiftUI animations let 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 useful animation
Springs resist stretching and compression. The greater the stretch or compression of the spring, the more resistance the spring presents. Imagine you take a weight and attach it to one end of a spring. Then you attach the other end of the spring to a fixed point and let the spring drop vertically with the weight at the bottom.
The weight of the object will stretch the spring to an equilibrium point where the pull of gravity exactly cancels out the resistance of the stretched spring.
If you now pull the weight down and then let it go, the spring’s stretch resistance is greater than the gravity on the weight. The spring will pull the weight upward. After some time the weight will be above that initial equilibrium point. Now gravity begins to exert more pull on the weight than the spring’s resistance causing it to slow then stop.
The weight will then begin to move downward under the greater pull of gravity. The weight will now continue to and pass the equilibrium point. Now the spring will again begin to exert more force than gravity slowing the weight down until it stops where originally released.
This cycle defines simple harmonic motion. In a frictionless world, an undamped system, this cycle would repeat forever.
If you were to graph the location of the weight against the time, you’d end up with this:
In the real world, friction and other outside forces ensure that the system loses energy each time through the cycle. This makes the system damped. These accumulated losses add up and eventually, the weight will return motionless to the equilibrium point. The graph of this movement looks more like this:
I’ll spare you the math, but four elements affect the shape of this graph:
- Mass: The mass of the weight. A larger weight will bounce for longer since gravity exerts more force on it than a smaller weight.
- Spring Resistance: How stiff the spring is and thus how much it resists when stretched or compressed
- Damping: How much friction and other forces affect the system. More damping means the weight slows down faster.
- Initial Velocity: In the example, you started the system by pulling the spring downward and letting go. If the weight isn’t motionless, then that velocity affects the system.
You might be asking, “So what?” Spring animations in SwiftUI simulate this damped simple harmonic motion from the real world. It turns out that these are the same four parameters that you traditionally specify when creating a spring animation. Changes to these parameters change the animation the same way changing the values would affect the real world motion of the weight.
Creating spring animations
Now that you have a bit more understanding of how a spring animation works, you’ll see how these parameters affect your animation. The traditional spring animation you create with the interpolatingSpring(mass:stiffness:damping:initialVelocity:) method uses these parameters. Change the animation line to:
animation(.interpolatingSpring(mass: 1, stiffness: 100,
damping: 10, initialVelocity: 0))
Preview the view and tap the details button. You’ll see the view slide in, continue a bit past the destination, slide back and then bounce around the final position before stopping.
The parameters affect the animation by:
-
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. You might want to temporarily add a second view so you can compare two animations with slightly different parameters.
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 has less effect on the length of the animation. Increasing the damping causes the animation to smooth and end faster. Increasing the initial velocity causes the animation to bounce further. A negative initial velocity causes a small lag in the system as (out of view) the movement has to overcome the initial velocity in the other direction.
The physical model of the animation 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))
The dampingFraction controls how fast the “springiness” stops. A value of zero will never stop. This corresponds to an undamped spring. A value of one or greater will cause the system to stop without oscillation. This overdamped state will look much like the eased animations of the previous section.
You will normally 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 time it takes the system to complete a single oscillation if the dampingFraction is set to zero. It allows you to tune the length of time before the animation ends.
The blendDuration parameter provides control for blending the length of the transition between different animations. A zero value turns off blending. It did not affect a single animation.
Again, try varying these parameters and compare the animations produced. Before moving to the next section, delete any extra views you created during the exercises.
Removing and combining animations
There are times that you may apply modifications to a view, but you only want to animate some of them. You do this by passing a nil to the animation() method.
Add another change to the chevron. Still in FlightBoardInformation.swift replace the code for the button, so it reads:
Button(action: {
self.showDetails.toggle()
}) {
HStack {
Text(showDetails ? "Hide Details" : "Show Details")
Spacer()
Image(systemName: "chevron.up.square")
.scaleEffect(showDetails ? 2 : 1)
.rotationEffect(.degrees(showDetails ? 0 : 180))
.animation(.easeInOut)
}
}
This adds a scaling to double the size of the image when showing the details view. 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. Add .animation(nil) between the scaleEffect() and rotationEffect() method and preview the animation again. You should now see the scale change take effect immediately with the fade-out/fade-in effect seen before you added any animation to the view.
You can combine different animations by using .animation() multiple times. Change the button class so instead of nil you pass a .spring() animation like below:
Button(action: {
self.showDetails.toggle()
}) {
HStack {
Text(showDetails ? "Hide Details" : "Show Details")
Spacer()
Image(systemName: "chevron.up.square")
.scaleEffect(showDetails ? 2 : 1)
.animation(.spring(response: 0.55, dampingFraction: 0.45,
blendDuration: 0))
.rotationEffect(.degrees(showDetails ? 0 : 180))
.animation(.easeInOut)
}
}
Preview the view and you will see the rotation has the easing animation as before. The scaling animation instead shows a little bounce from the spring animation.
Animating from state changes
To this point in the chapter, you’ve applied animations at the element of the view that changed. You can also apply the animation at the point where the state change occurs. When doing so, the animation applies to all changes that occur because of the state change. Modify the code for the Button and details view to:
Button(action: {
withAnimation(.default) {
self.showDetails.toggle()
}
}) {
HStack {
Text(showDetails ? "Hide Details" : "Show Details")
Spacer()
Image(systemName: "chevron.up.square")
.scaleEffect(showDetails ? 2 : 1)
.rotationEffect(.degrees(showDetails ? 0 : 180))
}
}
FlightDetails(flight: flight)
.offset(x: showDetails ? 0 : -UIScreen.main.bounds.width)
You removed the individual .animation(_:) modifiers. In their place, you have a new withAnimation() function inside the Button’s action: that wraps the state change to showDetails. This call uses the default animation, but you can pass any animation to this function.
If you preview the view and click the details button, you will see that all the elements the state change affects—the button text label, the chevron, and the details view—use the same animation. This gives a convenient way to apply animation for the same change to many places at the same time. If you apply an animation directly on the state change as you’d done previously in this chapter, it will override the animation at the state level.
Add a .spring() animation back to the flight details offset and notice that while the chevron animations remain the same, the view now slides in with the spring effect. Make sure to leave the spring animation in place for the next section.
Adjusting animations
There are a few instance methods common to all animations. These methods let you delay an animation, change the speed of the animation and repeat the animation.
Delay
The delay() method allows you to specify a time in seconds before the animation occurs. Change the spring animation added in the previous section so that the flight details view reads:
FlightDetails(flight: flight)
.offset(x: showDetails ? 0 : -UIScreen.main.bounds.width)
.animation(Animation.spring().delay(1))
Preview the flight details view and click the details button. You’ll notice the chevron animates immediately, but the details for the flight do not appear until one second later.
When you click the button again, the chevron again moves immediately, but the details for the flight do not slide away until one second passes.
A delay before an animation begins makes a great way to give animations a chained appearance.
Speed
You use the .speed() method to change the speed of the animation. This modifier multiplies the speed of the animation by the value you provide. If you have an animation that originally takes two seconds and apply .speed(0.5), it will occur at half of the original speed and therefore take twice as long to complete. This change causes the animation to last four seconds. This modifier can be useful to adjust the time of an animation lacking a direct time element such as default and spring animations. It also works well to match the times of concurrent animations.
Change the animation line on the offset to replace the delay() with a speed(_:) method call so it reads:
.animation(Animation.spring().speed(2))
Preview the view and you should see the spring animation is twice as fast. Speed changes also help you during development to slow down an animation to see fine details.
Repeating animations
To repeat an animation you call .repeatCount(_:autoreverses:) with the number of times the animation will repeat. You can also control if the animation reverses before repeating. Without reversing, the animation will return to the initial state instantaneously. With reversing, the animation goes back to the initial state before repeating. The repeatForever(autoreverses:) loops the animation forever, but you still specify if the animation should reverse before repeating.
Change the previous speed() method to use repeatCount(_:autoreverses:) so it reads:
.animation(Animation.spring()
.repeatCount(2, autoreverses: false))
Preview the view and tap the show details button. You should see the view appear, then disappear and appear again. This is the animation repeating twice. Since you told it not to reverse, the animation the view moved back offscreen without animation.
Change the false to true in the animation and preview again. Notice the difference. Remove these methods before continuing to the next section so it again looks like:
.animation(.spring())
Extracting animations from the view
To this point, you’ve defined animations directly within the view. For exploring and learning, that works well. In real apps, it’s easier to maintain code when you keep different elements of your code separate. Animation can be defined outside the view where you can also reuse them. In FlightBoardInformation.swift, add the following code above the body structure:
var flightDetailAnimation : Animation {
Animation.easeInOut
}
This defines a custom animation property. Now replace the withAnimation() in the button with:
withAnimation(self.flightDetailAnimation) {
Preview the view and confirm the animation did not change. You can make modifications to the animation on this property instead of adding clutter to your view code. For more complex animations, this will improve the readability of your code.
Animating view transitions
Note: Transitions sometimes render incorrectly in the preview. If you’re not seeing what you expect, then try running the app in the simulator or on a device.
You’ve applied animations on elements in a view and on a view. Transitions are specific animations for showing and hiding views. By default, views transition on and off the screen by fading in and out. You’ve likely noticed this in the initial view of the starter app and with the text of the button to toggle the details.
Much of what you’ve already learned about animations work with transitions. As with animation, the default transition is only a single possible animation.
Change the offset and animation code for the FlightDetails view to read:
if showDetails {
FlightDetails(flight: flight)
.transition(.slide)
}
Since transitions are a type of animation, you must specify the withAnimation() function around the state change or SwiftUI will not show the transition. For now, change the button to use only the default animation.
Button(action: {
withAnimation {
self.showDetails.toggle()
}
}) {
Preview the FlightBoardInformation view and tap the button to bring up the flight details. You’ll see that the view now slides in from the left. You’d done that before by modifying the offset, but now you didn’t need to specify anything related to positioning. SwiftUI took care of that for you. When you tap the button again, you’ll see the view slide off the trailing edge. These transitions handle cases where the text direction reads right-to-left for you.
Before, the flight details view always existed, but you positioned it off-screen. It still needed resources when not visible. Now the animation occurs when SwiftUI adds the view. The framework creates the view and slides it in from the leading edge. It also animates the view off the trailing edge and then removes it so that it’s no longer takes up resources.
You could do all these things with animations, but you would need to handle these extra steps yourself. The built-in transitions make it much easier to deal with view animations.
You can still use the animations you used earlier in this chapter on state changes. Add the following code after the .rotationEffect(_) call on the chevron:
.animation(flightDetailAnimation)
View transition types
You used a slide transition above. The slide transition slides a view from the leading edge and leaves by sliding off the trailing edge. There are several other transition animations you can use.
The default transition type changes the opacity of the view when adding or removing it. The view goes from transparent to opaque on insertion, and from opaque to transparent on removal. You can specify the transition using the .opacity transition.
The .move(edge:) transition moves the view from or to a specified edge when added or removed. To see the view move to and from the bottom, change the transition to:
.transition(.move(edge: .bottom))
The other edges are .top, .leading and .trailing.
Beyond moving, transitions can also animate views to appear on the screen. The .scale() transition causes the view to expand when inserted from a single point or collapse when removed to a single point at the center. You can optionally specify a scale factor parameter for the transition. The scale factor defines the ratio of the size of the initial view. A scale of zero provides the default transition to a single point. A value less than one causes the view to expand from that scaled size when inserted or collapse to it when removed. Values greater than one work the same except the opposite end of the transition is larger than the final view.
You can also specify an anchor parameter for the point on the view where the animation centers. An enumeration provides constants for the corners, sides, and center of the view. You can also specify a custom offset.
The final transition type allows you to specify an offset either as a CGSize or a pair of Length values. The view moves from that offset when inserted and toward it when removed. The result looks much like the animation you did with the view earlier in this chapter.
Exercise: As with animations, the best way to see how transitions work is to try them. Take each transition and use it in place of
.slidein the transition onFlightDetails. Toggle the view on and off and notice how the animation works when the view comes in and out.
Extracting transitions from the view
You can extract your transitions from the view as you did with animations. You do not add this at the struct level as with an animation but at the file scope. At the top of FlightBoardInformation.swift add the following:
extension AnyTransition {
static var flightDetailsTransition: AnyTransition {
AnyTransition.slide
}
}
This declares your transition as a static property of AnyTransition. Now update the transition on FlightDetails() call to use it:
if showDetails {
FlightDetails(flight: flight)
.transition(.flightDetailsTransition)
}
Preview the view and tap the button to watch the animation and you’ll see it works as the first transition example did.
Async transitions
SwiftUI lets you specify separate transitions when adding and removing a view. Change the static property to:
extension AnyTransition {
static var flightDetailsTransition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale(scale: 0.0)
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
You use the combined(with:) modifier to combine the two transitions together. Preview this new transition. You will see the view will move in from the trailing edge as it fades in. When SwiftUI removes the view, it will shrink down to a point while fading out.
Challenge
Challenge: Changing the flight details view
Change the final project for this chapter so that the flight details view slides in from the leading edge. When you hide it, make the view slide to the bottom and fade away. Also, change the button text transition. When added, the button text view should move from the leading edge. When removed, the button text view should vanish using a scale transition.
Hint, you will need to think about how SwiftUI applies transitions and animations and make a small change to how the button text view shows.
Key points
- Don’t use animations simply for the sake of doing so. Have a purpose for each animation.
- Keep animations between 0.25 and 1.0 second 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.
Where to go from here?
This chapter focused on how to create 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 goes into detail on gestures and motion in apps, which you can see, here: https://developer.apple.com/videos/play/wwdc2018/803.