19.
Animations & View Transitions
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, it’s challenging to show animations with static images in a book. In some cases, you will see pictures that use red highlights to reflect the motion to expect for some parts of this chapter. You will need to work through this chapter using the preview, the simulator or a device for the best idea of how the animations are working. 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(action: {
showTerminal.toggle()
}, label: {
HStack(alignment: .center) {
Text(
showTerminal ?
"Hide Terminal Map" :
"Show Terminal Map"
)
Spacer()
Image(systemName: "airplane.circle")
.resizable()
.frame(width: 30, height: 30)
.padding(.trailing, 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:) method 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 two seconds. 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")
.resizable()
.frame(width: 30, height: 30)
.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 method 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 method:
.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 the two .animation(_:value:) methods from the 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 DelayBarChart.swift in the SearchFlights group. This view contains the bar chart of flight delays created in Chapter 18: Drawing & Custom Graphics. You’re going to add some animation to the bars when they appear. First, add a state variable to the struct below the flight property.
@State private var showBars = 0.0
Now change the minuteLength(_:proxy:) method to:
func minuteLength(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
let pointsPerMinute = proxy.size.width / minuteRange
return CGFloat(abs(minutes)) * pointsPerMinute * showBars
}
The last line now multiples the bar’s length by the showBars state variable. Setting showBars to zero means the bar will not show as it has a zero-length. Setting showBars to one will display the full size. Now add the following code to the first Rectangle shape inside the GeometryReader after the offset(x:y:) modifier:
.animation(
.easeOut.delay(0.5),
value: showBars
)
You apply a default ease-out animation when showBars changes. You add a half-second delay to give the view time to show before the animation begins. 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 VStack add the following code:
.onAppear {
showBars = 1.0
}
Code inside onAppear(perform:) executes when the attached view appears on the device. Here you change the value of showBars to 1.0, which will change the length of the Rectangle because the width of the bar changes thanks to the change made to minuteLength(_:proxy:)
Run the app, tap Search Flights and choose the first flight US 810 to Denver. Tap on Flight Time History. You’ll see after that half-second pause, the bars appear.
Notice that the bars for the early flights appear from the left and grow toward the zero point. That’s because you only changed the length of the bar and not the offset. To fix this, change the minuteOffset(_:proxy:) method to:
func minuteOffset(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
let pointsPerMinute = proxy.size.width / minuteRange
let offset = minutes < 0 ? 15 + minutes * Int(showBars) : 15
return CGFloat(offset) * pointsPerMinute
}
The only change is the calculation of the offset. When showBars is one, it acts as before. When showBars is zero, then the bar’s offset also becomes zero from the multiplication. Now the offset animates from the zero point to the final position as the length increases. The visual result is that the bar moves to the left when it gets longer, causing it to appear from the zero point.
The delay() method also gives you a way to make animations appear to connect. In the next section, you’ll change the bar chart to include this effect.
Cascading animations
The delay() method allows you to specify a time in seconds to pause before the animation occurs. You used it in the previous section so the view was fully displayed before the bars were animated.
You can also use it to allow animations to chain together and provide a sense of progress or motion.
Open DelayBarChart.swift and change showBars to:
@State private var showBars = false
This changes showBars to a boolean. Change minuteOffset(_:proxy:) and minuteLength(_:proxy:) back to the original code:
func minuteLength(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
let pointsPerMinute = proxy.size.width / minuteRange
return CGFloat(abs(minutes)) * pointsPerMinute
}
func minuteOffset(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
let pointsPerMinute = proxy.size.width / minuteRange
let offset = minutes < 0 ? 15 + minutes : 15
return CGFloat(offset) * pointsPerMinute
}
Now change the Rectangle frame and offset for the bar to:
.frame(
width: showBars ?
minuteLength(history.timeDifference, proxy: proxy) :
0
)
.offset(
x: showBars ?
minuteOffset(history.timeDifference, proxy: proxy) :
minuteOffset(0, proxy: proxy)
)
You’ve moved the state change from the calculation method directly into the view code. Doing so means you can also move the animation there as you did at the start of the chapter. Remove the withAnimation(_:_:) inside onAppear(perform:) so it reads:
.onAppear {
showBars = true
}
Run the app to verify that the animation looks the same as before.
Now you can add the delay to the animation. Change the animation after the offset on the Rectangle to:
.animation(
.easeInOut.delay(Double(history.day) * 0.1),
value: showBars
)
Since you’ve moved the state change into the view, you can now apply a different animation to each iteration through the ForEach loop. This code now uses the day property as a counter to steadily increase the delay before each animation shows. The bar for day one delays 0.1 seconds. The bar for the tenth day delays a full second.
Now run the app. You’ll see the result of the delayed animations as the bars appear one after the other providing a bit more visually exciting display.
Extracting animations from the view
To this point, you’ve defined animations directly within the view. For exploring and learning, that works well. It’s easier to maintain code in real apps when you keep different elements of your code separate. Doing so also lets you reuse them. In DelayBarChart.swift, add the following code above the body structure:
func barAnimation(_ barNumber: Int) -> Animation {
return .easeInOut.delay(Double(barNumber) * 0.1)
}
You define a custom animation method much as with any other property or function. It should return an Animation structure. Now replace the animation(_:value:) in the view with:
.animation(
barAnimation(history.day),
value: showBars
)
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 another complex animation adding a visual indicator to the terminal map.
Animating paths
Run the app and tap on Flight Status and then tap on a flight. Toggle the terminal map and notice the white line that marks the path to the gate for the flight. Open FlightTerminalMap.swift in the FlightDetails group, and you’ll see the line is determined using a set of fixed points scaled to the size of the view. 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(Color.white, 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 — that there’s enough 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.
Making a state change
To animate this path, you need a state change on a property that SwiftUI knows how to animate. Animations function because of the Animatable protocol. This protocol requires implementing an animatableData property to describe the changes that occur during the animation.
You can use any type that implements the VectorArithmetic protocol for animatableData. The built-in implementations for Float, Double, and CGFloat do, and you’ve been using those in this chapter.
A SwiftUI Shape has a method trim(from:to:) that trims a shape by a fractional amount 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, add a new state variable after the flight parameter at the top of the struct:
@State private var showPath = false
Next, add the following code after the current FlightTerminalMap 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. You pass in the array of points and create the path as before.
Back in the FlightTerminalMap struct, add a new animation property after the mapname 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 you added:
WalkPath(points: gatePath(proxy))
.trim(to: showPath ? 1.0 : 0.0)
.stroke(Color.white, 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 overlay():
.onAppear {
showPath = true
}
You use the onAppear(perform:) method to start the animation when the view appears.
Run the app, and show any terminal map. You’ll see the path trace out to the gate and then repeat every three seconds.
Animating view transitions
Note: Transitions often render incorrectly in the preview. If you do not see what you expect, try running the app in the simulator or on a device.
The first thing you should understand is the difference between a state change and a view transition. A state change occurs when an element on a view changes. A transition involves changing the visibility or presence of a view.
Open FlightInfoPanel.swift and look for the Text view between the icons in the button that shows the terminal map.
Right now, it looks like:
Text(
showTerminal ?
"Hide Terminal Map" :
"Show Terminal Map"
)
This code shows a state change. The view is the same, but the text displayed by the view can change. Change the code to:
if showTerminal {
Text("Hide Terminal Map")
} else {
Text("Show Terminal Map")
}
Now you have a view transition. One view gets replaced by a different view when the showTerminal state variable changes.
Transitions are specific animations that occur when showing and hiding views. You can confirm this by running the app, tapping on Flight Status, then tapping on any flight. Tap the button to show and hide the terminal map a few times and notice how the view disappears and reappears. By default, views transition on and off the screen by fading in and out, respectively.
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 code that shows the button text to:
Group {
if showTerminal {
Text("Hide Terminal Map")
} else {
Text("Show Terminal Map")
}
}
.transition(.slide)
You use the Group method to wrap the view change. You then apply the transition to the group. Run the app, go back to the page, and you’ll see — something odd. The old view slides away but doesn’t disappear for a few seconds. Since transitions are a type of animation, you must use the withAnimation(_:value:) function around the state change, or SwiftUI will not show the specified transition. You already did this as the action for the button is now:
Button(action: {
withAnimation(
.spring(
response: 0.55,
dampingFraction: 0.45,
blendDuration: 0
)
) {
showTerminal.toggle()
}
}, label: {
As a result, SwiftUI applies both the animation and transition. You’ll run into this type of issue often working with animations and transitions, which makes keeping animations with the UI element to change more manageable. For now, change the button to use the withAnimation method without an animation type.
Button(action: {
withAnimation {
showTerminal.toggle()
}
}, label: {
There’s no animation specified in the withAnimation(_:value:) call. It’s not needed since you set it at the individual elements in the view. To keep the animations on the plane icons, add the following code after each Image view:
.animation(
.spring(
response: 0.55,
dampingFraction: 0.45,
blendDuration: 0
),
value: showTerminal
)
Run the app, and bring up the details for a flight. Now tap to show the terminal map, and you’ll see that the view now slides in from the leading edge. 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.
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 removes it to no longer take up resources.
You could create a similar result with animations, but you need to handle these extra steps yourself. The built-in transitions make it much easier to deal with view animations.
View transition types
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 create a more customized version using the .opacity transition.
You also used a slide transition that inserts a view from the leading edge and removes it off the trailing edge. 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. A .scale() transition causes the view to expand when inserted from a single point or to 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 view at the 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 transition centers. An enumeration provides constants for the corners, sides, and center of the view. You can also provide 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.
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
.slidetransition in theFlightTerminalMap. Toggle the view on and off and notice how the animation works as the view appears and leaves.
Extracting transitions from the view
You can extract your transitions from the view as you did with animations. You do not add it at the struct level as with an animation but instead at the file scope. At the top of FlightInfoPanel.swift add the following:
extension AnyTransition {
static var buttonNameTransition: AnyTransition {
.slide
}
}
This extension declares your transition as a static property of AnyTransition. Now update the transition on FlightDetails call to use it:
if showTerminal {
FlightTerminalMap(flight: flight)
.transition(.buttonNameTransition)
}
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 different transitions when adding and removing a view. Change the static property to:
extension AnyTransition {
static var buttonNameTransition: 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. 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.
Now that you’ve learned about animation and transitions, you’ll see how to link transitions into more complex animations.
Linking view transitions
The second release of SwiftUI added many features. The one you’ll use in this section is the matchedGeometryEffect method. It allows you to synchronize the animations of multiple views. Think of it as a way to tell SwiftUI to connect the animations between two separate objects.
Open AwardsView.swift under the AwardsView group. This view displays awards using a grid you developed in Chapter 16: Grids. When you tap on an award, it transitions to a new view displaying that award’s details. You’re going to change it to instead popup the award details over the grid.
Add the following code to the top of the view after the flightNavigation EnvironmentObject:
@State var selectedAward: AwardInformation?
When the user taps on an award, you’ll store it in this optional state variable. Otherwise, the property will be nil. Since that tap action takes place in a subview, you’ll need to pass this into that subview.
Open AwardGrid.swift. Add the following binding after the awards property:
@Binding var selected: AwardInformation?
You’ll pass the state from the AwardsView to the AwardGrid using this binding. Change the contents of the ForEach loop to:
AwardCardView(award: award)
.foregroundColor(.black)
.aspectRatio(0.67, contentMode: .fit)
.onTapGesture {
selected = award
}
You’ve removed the navigation link and instead added an onTapGesture(count:perform:) method to set the binding to the tapped award. You also need to update the preview to add the new binding parameter. Change it to:
AwardGrid(
title: "Test",
awards: AppEnvironment().awardList,
selected: .constant(nil)
)
Now go back to AwardsView.swift and change the view to:
ZStack {
// 1
if let award = selectedAward {
// 2
AwardDetails(award: award)
.background(Color.white)
.shadow(radius: 5.0)
.clipShape(RoundedRectangle(cornerRadius: 20.0))
// 3
.onTapGesture {
selectedAward = nil
}
// 4
.navigationTitle(award.title)
} else {
ScrollView {
LazyVGrid(columns: awardColumns) {
AwardGrid(
title: "Awarded",
awards: activeAwards,
selected: $selectedAward
)
AwardGrid(
title: "Not Awarded",
awards: inactiveAwards,
selected: $selectedAward
)
}
}
.navigationTitle("Your Awards")
}
}
You now have a ZStack that shows one of two views depending on the if statement results. The code inside the else condition didn’t change other than passing the binding to the selectedAward state variable. There are some changes worth noting:
- The first change is that you attempt to unwrap the state
selectedAwardstate variable. If that fails, you show the grid as before in theelsepart of the statement. - If the unwrapping succeeded, you display the
AwardDetailsview that previously was theNavigationLinktarget. - You set the
selectedAwardstate variable back tonilwhen the user taps on the view. This change removes theAwardDetailsview and displays the grid. - You set the title to the name of the current award
Run the app. Tap on Your Awards and then tap on any award in the grid. You’ll see the view flips to the large details display for the award. Tap the AwardDetails view, and the grid appears again.
The transition is abrupt. You know you can fix that by adding a view transition. Find the onTapGesture method in AwardsView (under comment three) and change it to:
.onTapGesture {
withAnimation {
selectedAward = nil
}
}
From earlier in this chapter, you should recall this tells SwiftUI to animate events caused by the state change in the closure. For the other end of the transition, find the onTapGesture method in AwardGrid and change it to:
.onTapGesture {
withAnimation {
selected = award
}
}
Run the app, and you’ll find the change works better. Now you have a nice fade-out/fade-in effect that smooths the previously harsh transitions between the views. There’s still no sense connecting the changes. The two views that you’re transitioning between are separate with no connection. That’s where matchedGeometryEffect(id:in:properties:anchor:isSource:) comes in. It lets you connect the two view transitions.
You only must specify the first two parameters. The id works much like other ids you’ve encountered in SwiftUI. It uniquely identifies a connection, so giving two items the same id links their animations. You pass a Namespace to the in property. The namespace groups related items, and the two together define unique links between views.
Creating a namespace is simple. At the top of AwardsView, add the following code after the selectedAward state variable.
@Namespace var cardNamespace
You now have a unique namespace for the method. Now, after the onTapGesture method attached to AwardDetails, add the following:
.matchedGeometryEffect(
id: award.hashValue,
in: cardNamespace,
anchor: .topLeading
)
You use the existing hashValue property as the identifier along with your created namespace. You can use any identifier as long as it is unique within the namespace and consistent. You also specify the anchor parameter to specify a location in the view used to produce the shared values. It’s not always needed, but in this case, it improves the animation.
You now have one side set up but need to link the state change in the subview. To do so, you need to pass the namespace into that view. Change the AwardGrids inside the LazyVGrid to add it as a parameter:
AwardGrid(
title: "Awarded",
awards: activeAwards,
selected: $selectedAward,
namespace: cardNamespace
)
AwardGrid(
title: "Not Awarded",
awards: inactiveAwards,
selected: $selectedAward,
namespace: cardNamespace
)
Now open AwardGrid. First, you need to add a property to capture the passed namespace. After the selected binding, add the following code:
var namespace: Namespace.ID
When you pass a namespace into a view, it comes in as a Namespace.ID type.
You also need to update to preview to pass this new parameter. Add the following to the top of AwardGrid_Previews struct:
@Namespace static var namespace
Update the view to:
AwardGrid(
title: "Test",
awards: AppEnvironment().awardList,
selected: .constant(nil),
namespace: namespace
)
Now add the following code after the onTapGesture(count:perform:) call:
.matchedGeometryEffect(
id: award.hashValue,
in: namespace,
anchor: .topLeading
)
Notice this uses the namespace you passed in and therefore is the same namespace as the parent view. You also use the hashValue property on the award, again the same as used in the parent view. With the two parameters matching, SwiftUI knows to link the transitions.
Run the app now. When you tap an award in the small grid, it shifts and expands while changing to the AwardDetails view. Similarly, when you tap the AwardDetails view, it appears to shrink and move back to the smaller view inside the grid.
Adding matchedGeometryEffect() only arranges for the geometry of the views to be linked. The usual transition mechanisms applied to the views still take place during the transition.
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 frame of the animation. - 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 to the start of the ScrollView:
WelcomeAnimation()
.foregroundColor(.white)
.frame(height: 40)
.padding()
Run the app, and you’ll see your animation works as a small, white asterisk will slide above the buttons on the view.
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.
symbols: {
Image(systemName: "airplane")
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(height: 40)
.tag(0)
}
Make sure the code starts immediately after the closing brace of the current closure 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. Each view inside the symbols closure must be given 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 tag(_:) modifier of the view. 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.
- Using
matchedGeometryEffectlets you link view transitions into a single animation. - You can create high-performance animations by combining
TimelineViewandCanvas.
Where to go from here?
You can read more about animations in Getting Started with SwiftUI Animations at https://www.raywenderlich.com/5815412-getting-started-with-swiftui-animations.
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.