18.
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 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 decisive 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. 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 on a printed page. You’ll need to work through this chapter using the preview, the simulator or on a device. The preview makes tweaking animations a lot easier, but sometimes animations won’t look quite right in the preview. When you don’t see the same thing in the preview described here, try running the app in the simulator or on a device.
Adding animation
To start, open FlightInfoPanel.swift 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 creates a button toggling 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 angle of the rotationEffect 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. You’ll first add an animation to this rotation.
In SwiftUI, you just tell SwiftUI the type of animation, and it handles the interpolation for you. After the .rotationEffect(_:anchor:) method add the following code:
.animation(.linear(duration: 1.0))
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 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() modifier. The animation only applies to the Image element’s rotation and no other views on the page.
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. 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:
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(.trailing, 10)
.rotationEffect(.degrees(showTerminal ? 90 : 270))
.animation(.linear(duration: 1.0))
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(Animation.default.speed(0.33))
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.
Run the app and go to the details for a flight. While not identical, you’ll see the animations run at similar speeds. You should also notice that you specified the Animation structure and not just the property name when adding this modifier.
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. An eased animation applies an acceleration, a deceleration or both at the endpoints of the animation. They generally look more natural since something can’t change speed in the real world instantaneously. 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))
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))
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:
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(_:_:_:_) 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 SwiftUI animations category 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 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
)
)
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. You can 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 has less effect on the animation’s length. Increasing the damping smoothes and ends it faster. Increasing the initial velocity causes the animation to bounce further. A negative initial velocity 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
)
)
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 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 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
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.
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 original size of the icon 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.
Add the following code between the rotationEffect() and scaleEffect() methods:
.animation(nil)
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() multiple times. Change the animation on the rotationEffect() from nil to:
.animation(.linear(duration: 1))
Run the app, and you’ll see the two animations take place simultaneously and blend smoothly. 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.
Before moving on, change both icons to:
Image(systemName: "airplane.circle")
.resizable()
.frame(width: 30, height: 30)
.padding(.trailing, 10)
.rotationEffect(.degrees(showTerminal ? 90 : 270))
.animation(
.spring(
response: 0.55,
dampingFraction: 0.45,
blendDuration: 0
)
)
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 where the state change occurs. When doing so, the animation applies to all changes that occur because of the state change.
Open DelayBarChart.swift in the SearchFlights group. This view contains the bar chart of flight delays created in Chapter 17: 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 = CGFloat(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.
You need to trigger the state change causing the animation. Here you’ll trigger it when the view appears. At the end of the VStack add the following code:
.onAppear {
withAnimation(Animation.default.delay(0.5)) {
showBars = CGFloat(1)
}
}
Code inside onAppear(perform:) executes when the attached view appears on the device. You wrap the state change to showBars inside a withAnimation() function. This call uses the default animation, but you could pass any animation to this function. You add a half-second delay to give the view time to show before the animation begins.
Run the app, and go to a Flight Time History view. You’ll see after that pause, the bars appear. Notice that the bars for the early flights appear from the left and grow toward the middle. 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, making it appear to come from the center now.
The delay() method also gives you a method 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 fully displayed before the bars animated.
You can also use it to allow animations to chain together. This connects animations and provides a sense of progress or motion.
Open DelayBarChart.swift and change showBars to:
@State private var showBars = false
This changes showBars to a boolan. Remove the references to the property from minuteOffset(_:proxy:) and minuteLength(_:proxy:).
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. After the offset on the Rectangle, add the following code:
.animation(
Animation.easeInOut.delay(Double(history.day) * 0.1)
)
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 Animation.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() in the view with:
.animation(barAnimation(history.day))
Run the app and confirm the animation did not change. You could reuse this animation elsewhere in the view and only change in one place. For more complex animations, extracting the animation also improves the readability of your code.
Now that you have a basic understanding of animations, you’ll look at implementing more complex animations.
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 and you’ll see the line is determined using a set of fixed points that are 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 17: 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 {
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)
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 shown 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.
Since transitions are a type of animation, you must specify the withAnimation() function around the state change, or SwiftUI will not show the specified transition. Change the button to use the withAnimation method.
Button(action: {
withAnimation {
showTerminal.toggle()
}
}, label: {
There’s no animation specified in the withAnimation() call. It’s not needed since you set it at the individual elements in the view.
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, so it no longer takes up resources.
You could do a similar display 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.
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 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 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.
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 {
AnyTransition.slide
}
}
This 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 separate 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.
Before continuing, remove transition from the FlightTerminalMap. Now that you’ve learned about animation and transitions, you’ll see how you can 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 15: 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 that will 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
}
} else {
ScrollView {
LazyVGrid(columns: awardColumns) {
AwardGrid(
title: "Awarded",
awards: activeAwards,
selected: $selectedAward
)
AwardGrid(
title: "Not Awarded",
awards: inactiveAwards,
selected: $selectedAward
)
}
}
}
}
You now have a ZStack which shows one of two views depending on the results of the if statement. The code inside the else condition didn’t change other than passing the binding to the selectedAward state variable. There are two 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. - When the user taps on the, you set the
selectedAwardstate variable back tonil. This change removes theAwardDetailsview and again displaying the grid.
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 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 and 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 it. 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 that you passed in, and therefore is the same namespace as in 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. It causes no change to the rendering takes place.
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 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.
- Using
matchedGeometryEffectlet’s you link view transitions into a single animation.
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.