Explore SwiftUI's powerful animation capabilities and find out how these features work together to produce impressive visual effects. Learn how SwiftUI refreshes the rendering of a view, determines what to animate, interpolates values over time, and propagates context for the current transaction.
♪ ♪ Kyle: Hi, I'm Kyle a member of the SwiftUI team. Animation is a key component of modern app design. When tastefully applied, it can bring both clarity and life to your UI. Making it simple to add animation to your app was one of our core motivations when we began developing SwiftUI. It's a big reason why SwiftUI is shaped the way that it is. This session is an overview of SwiftUI's powerful animation capabilities and how they work together to produce impressive visual effects. I'll cover how SwiftUI refreshes the rendering of a view, determines what to animate using Animatable, interpolates values over time using Animation, and propagates context for the current update using Transaction.
In recent years, there's been debate among my colleagues about who makes the best furry-- or not so furry--companion.
We were curious if we could come to a consensus, so we made an app to take a poll.
There's a button to cast a vote for each pet. When you tap, the vote count changes, and the avatars slide around to reflect the current standings.
In the last poll, as is proper, cats came in first place, but only narrowly. The stakes are too high to leave this next poll purely to chance, so I'm adding a new feature. On tap, the avatar of my choice will scale up in order to nudge people towards voting for the proper pet. And I can tap again to scale it back down. This is already working pretty well, but it would be way better with an animation.
Before adding that, I'm gonna trace through how SwiftUI refreshes the rendering of a view to give you a better understanding of the anatomy of a view update. For this exercise, I'm gonna focus on the pet avatar view in isolation.
SwiftUI tracks a view's dependencies, like this selected state variable. When an event, like a tap, comes in, an update transaction is opened.
If any of its dependencies change, the view is invalidated, and at the close of the transaction, the framework calls body to produce a new value in order to refresh the rendering.
This view's body is composed of a tap gesture, a scale effect, and an image.
Behind the scenes, SwiftUI maintains a long-lived dependency graph that manages the lifetime of views and their data.
Each node in this graph, called an attribute, maps to a fine-grained piece of the UI. When the selected state changed to true, the value of each of these downstream attributes became stale. They're refreshed by unwrapping the new view value one layer at a time.
Once the corresponding graph attributes have been updated, the view's body value is discarded.
Finally, the graph emits drawing commands on your behalf to update the rendering.
I'll zoom in on just the graph in order to visualize the lifetime of an attribute.
An attribute is born with an initial value. An event comes in, and an update transaction opens. An upstream dependency changes. The framework calls body. The attribute's value is updated. The transaction closes. And in this way, the current value of each attribute in the graph evolves over time.
So that's the anatomy of a view update. Now I'll add an animation.
If I wrap withAnimation around my state change, when the tap gesture closure fires, the animation is set for the transaction. Then selected is toggled, and the downstream attributes are invalidated. As before, body is called to provide new attribute values.
And here's where it gets interesting. scaleEffect is a special attribute, an "animatable attribute." When the value of an animatable attribute changes, it checks if an animation is set for the transaction.
If so, it makes a copy and uses the animation to interpolate from the old value to the new value as time passes.
I'll zoom in on the scaleEffect animatable attribute to examine how this plays out. The first thing to notice is that animatable attributes conceptually have both model and presentation values. Right now they're the same. Then an event comes in, and a transaction is opened, this time with an animation. State is changed, and body is called to refresh the stale attribute values. Because the value has changed, the attribute makes a local copy of the animation to calculate the current presentation value.
SwiftUI knows when the attribute graph contains running animations and will call into the appropriate animatable attributes to produce the next frame. For built-in animatable attributes like scaleEffect, SwiftUI is very efficient. It's able to do this work off the main thread and without calling into any of your view code.
Here's the animation in action.
When someone uses the word "animation," they're probably referring to the overall visual experience of the way a view changes over time.
What I've covered so far is that, in SwiftUI, there are two orthogonal aspects contributing to the overall visual experience. Animatable attributes, like scaleEffect, determine the data being animated, while the Animation determines how that data changes over time.
I'll dive deeper into each of these in turn, starting with Animatable, which determines what to animate. SwiftUI builds an animatable attribute for any view conforming to the Animatable protocol. The only requirement is that the view define a readwrite vector of the data it wants to animate. The data must conform to VectorArithmetic.
VectorArithmetic matches the textbook definition of a vector from your math class. It supports vector addition and scalar multiplication.
If you're rusty or not familiar with vectors, don't be discouraged. A vector is basically just a fixed-length list of numbers, and for SwiftUI animation, the purpose of dealing in vectors is mostly just to abstract over the length of that list.
For example, CGFloat and Double are one-dimensional vectors, while CGPoint and CGSize define two-dimensional vectors, and CGRect defines a four-dimensional vector.
By dealing in vectors, SwiftUI is able to animate all of these types and more with a single generic implementation.
So far, for the sake of simplicity, I've represented scaleEffect as if it were a one-dimensional scale factor.
The Animatable conformance for a one-dimensional scaleEffect would be straightforward. Its animatableData would just be a CGFloat.
In reality, scaleEffect allows you to independently configure the width, height, and relative anchor point of the transformation--all animatable. So scaleEffect actually defines a four-dimensional vector for its animatable data, a CGSize for the width and height scale, paired with a UnitPoint for the relative anchor.
AnimatablePair fuses the two vectors together into a single, larger vector. It's a public type, and you can use it too. It might come in handy if you're conforming one of your own views to Animatable.
scaleEffect is just one of the many animatable visual effects that come built into SwiftUI, so the vast majority of the time, Animatable is not an API you'll need to use directly. In rare circumstances, though, an advanced use case may call for conforming one of your own views to Animatable.
Consider the pet Podium view, which distributes its subviews along the arc of a circle using a custom RadialLayout. By default, changing the offset angle within an animation animates the pet avatars to their new positions along a straight line. Notice how the pets are taking a shortcut and trespassing on the interior of the circle? That's not what I want.
Instead, I want my avatars to animate along the perimeter of the circle. I can get this effect by conforming Podium to animatable and using the offset angle as its animatable data.
Why does this result in such a different effect? To explain, I'll step through an animated update for each version of the Podium view, starting with the default behavior, which animates the avatars along a straight line.
Podium's body is composed of a RadialLayout and three avatars. When a transaction opens, if the offset angle has changed, body is called to refresh the stale downstream attribute values. Then layout is run, updating the position of each subview.
So this is what an animated update is like in the default version. The active animatable data is the view position CGPoint, which interpolates in a cartesian coordinate space, meaning each avatar moves along a straight line. In the custom version, when I conform Podium to Animatable, what changes is that body becomes the active animatable attribute, with the offset angle as its animatable data. How does this result in each avatar moving along an arc? In this custom version, for every frame of the animation, body is going to be called by SwiftUI with a new offset angle, and the layout will be rerun.
This is super powerful, and sometimes, like when you're animating custom layout or drawing code, it might be the only way to achieve the effect you're going for.
Just keep in mind that a custom Animatable conformance can be much more expensive to animate than a built-in effect because it'll run body for every frame of the animation. So only use this tool if you can't achieve the effect you're going for using the built-in effects.
Next, I'll cover Animation, the generic algorithms that interpolate animatable data over time.
Earlier, I added an animation to the pet avatar view by wrapping the state change in withAnimation.
You can customize this by passing an explicit animation, like a bouncy spring.
SwiftUI comes with a ton of powerful animations built in. They can roughly be categorized into three buckets: Timing curve animations, Spring animations, and Higher order animations, which modify a base animation.
Timing curve animations are likely the category of animation you're most familiar with. For example, easeInOut is a timing curve animation.
All timing curve animations take a curve, which defines the speed of the animation, and a duration.
A timing curve can be created using bezier control points. By adjusting the start and end control points, you change the initial and final velocity of the animation.
The UnitCurve type can be used standalone to calculate the value and the velocity at a relative point between 0 and 1.
SwiftUI comes with a number of built-in timing curve presets: Linear, easeIn, easeOut, and easeInOut.
All timing curve animations can also specify a custom duration.
The next category of animation, Springs, determine the value at a given point in time by running a spring simulation.
You may be familiar with traditional ways of specifying a spring, for example, mass, stiffness, and damping. But we've never found these ways to be particularly intuitive, so we invented a new way. You just specify the perceived duration of the animation and how bouncy you want the spring to be. It's much more approachable.
Similar to UnitCurve, the Spring type can be used standalone to calculate the value and the velocity of a spring at a given time.
SwiftUI comes with three built-in spring presets: Smooth, which has no bounce, snappy, which has a small amount of bounce, and bouncy, which has a larger amount of bounce.
If you're uncomfortable parameterizing a spring animation, these presets are a reliable way to get something that feels good.
Each preset can be also tuned to adjust the duration or tweak the bounciness.
We highly recommend using spring animations because they give your UI an organic feel by preserving velocity and naturally coming to rest. In fact, we feel so strongly about the benefits of spring animations that we made a smooth spring the new default when you use a bare withAnimation in iOS 17 and aligned releases.
The last category of animations, Higher order, modify a base animation. They can slow it down or speed it up. They can add a delay before the base animation starts. And they can repeat a base animation any number of times, optionally toggling between playing forwards and playing in reverse.
And now we're introducing an entirely new category of animation: Custom animations. The CustomAnimation protocol gives you access to the same low-level generic entry points we use to implement all the animations that come built into SwiftUI.
The CustomAnimation protocol has three requirements: animate, shouldMerge, and velocity.
I'll start by focusing on animate. shouldMerge and velocity are optional requirements. I'll get back to them later.
Animate is passed the vector to animate towards, the amount of time that has elapsed since the animation began, and the context, which includes additional animation state.
Animate returns the current value of the animation, or nil if the animation has finished.
Where does this value vector come from? It comes from a view's animatable data. In the pet avatar view, that's the scale effect.
Recall that scaleEffect's animatable data is a four-dimensional vector, including a two-dimensional width and height scale. When the avatar is selected, it's animated to a scale factor of 1.5 by 1.5 from a scale factor of 1 by 1.
Vector addition and scalar multiplication operations allow SwiftUI to subtract these two vectors from one another to calculate the delta between them.
This delta is actually what's being animated.
This means, in practice, the animation running in the scaleEffect animatable attribute isn't interpolating from 1 to 1.5, but from 0 to 0.5. Among other things, this makes it more convenient to implement the animate method. Let me show you.
I'm going to implement a linear timing curve animation configured by a duration to interpolate over.
Recall that animate is passed the delta vector to animate towards. I can use scalar multiplication to scale the vector by the proportion of the duration that has elapsed. And once the full duration has elapsed, I'll return nil to indicate that the animation has finished and can be removed. That's it. And because this implementation is generic, it works with animatable data of any number of dimensions. So that's how Animatable and Animation work together to produce the impressive visual effects that appear in your UI.
Next, I'll return to CustomAnimation's two optional requirements: shouldMerge and velocity. What are they for? Imagine for a moment that you're the scaleEffect animatable attribute. The user taps down, a transaction opens, your value changes, you make a local copy of the animation, and you start happily animating your delta vector. Everything is going great. That is, until the pesky user taps again before the animation has finished. What do you do? You set up a new animation, and you call shouldMerge on it.
The default implementation returns false-- this is what timing curve animations do-- in which case both animations will be run together, and their results will be combined by the system.
This is another reason why SwiftUI animations deal in terms of delta vectors. It makes it easy to calculate the correct combined presentation value when multiple animations are running. But what if I'd chosen a spring animation, not a timing curve animation? Spring animations override shouldMerge to return true and incorporate the state of the previous animation. This allows them to preserve velocity and retarget to the new value, which can feel more natural than combining additively, like timing curve animations.
And that's what this final velocity requirement is for. Implementing it allows velocity to be preserved when a running animation is merged with a new one. So I'll finish off my linear timing curve animation by adding an implementation for velocity.
I've used the term "transaction" throughout this talk to refer to the set of work that's performed for a given update to the UI. In SwiftUI code, Transaction also refers to a related, powerful data-flow construct and family of APIs. You may already be familiar with Environment and Preferences, dictionaries SwiftUI implicitly passes down and up the view hierarchy, respectively. Transaction is similar. It's a dictionary SwiftUI uses to implicitly propagate all the context for the current update, most notably the animation.
My explanation earlier of how an animatable attribute reads the current animation was a little vague. So I'm gonna trace through another animated update of the avatar view. This time, I'll be more specific. When the tap gesture closure fires, withAnimation sets an animation in the root transaction dictionary.
Body is called to update the attribute values. The transaction dictionary is propagated across the attribute graph. When it reaches an animatable attribute, the attribute checks if an animation is set. And if so, it makes a copy to drive its presentation value. The Transaction is only relevant for a specific update, so once the stale attributes have been refreshed, it's discarded.
Flowing the animation down the view hierarchy within the transaction dictionary makes possible a number of powerful APIs for controlling when and how animations apply to your views.
Right now, the pet avatar view can only be selected via tap. I'll change the selected State variable to a Binding. That way, it can be also be selected programmatically.
But how do I animate a programmatic change to a view property? I can use the transaction modifier to access the animation as it flows down the view hierarchy inside the transaction dictionary.
If I set an animation from within this modifier, then whenever body is called, even if there is no animation or a different animation in the transaction, the attribute will override the animation. And when it reaches the scale effect, this animation will be used to interpolate the scale factor.
But there's a problem with this pattern. Indiscriminately overriding the animation for all descendants whenever SwiftUI refreshes a view can lead to accidental animations. Instead, for uses cases like this, SwiftUI provides an animation view modifier. It takes an additional value argument, which allows you to scope the effect much more precisely. It will only write the animation into the transaction if the value has changed.
Now that that's hooked up, this withAnimation isn't accomplishing anything, so we can remove it.
The animation view modifier is also a powerful tool for situations where you want to apply different animations to different parts of a view.
For example, the pet avatar has a shadow, which I've omitted thus far from the example for the sake of simplicity. The shadow radius increases when the avatar is selected to accentuate the illusion that it's elevated above the background.
After playing with it, I've decided I want the shadow's animation to be more subdued than the scale effect's. To accomplish this, I can insert another animation view modifier between the scaleEffect and the shadow. Now the transaction picks up the bouncy spring for animating the scale effect.
And it picks up the more subtle smooth spring for animating the shadow radius.
Because animation modifiers are only active when their value has changed, the odds of an accidental animation are greatly reduced. But if the avatar's image had just happened to change in the same transaction as selected, it would have inherited the shadow's smooth spring animation for its content transition.
This is a point worth lingering on. This animation view modifier works well for leaf components where the entire sub-hierarchy is under your control. But for non-leaf components, which contain arbitrary child content, it's much more likely for an accidental animation to occur.
For example, if I want to reuse my avatar in another app that has nothing to do with pets, I could make it more generic by accepting arbitrary child content. In this scenario, I have less of a guarantee that when selected changes, the child content won't also have changed.
This could result in an accidental animation. Whoops. Good news. We have a new version of the animation view modifier specifically designed for use cases like this. It narrowly scopes the animation to the animatable attributes specified in its body closure. Here's how it works. Imagine there's no animation in the transaction. When the transaction reaches the animation view modifier's attribute, a copy is made that's populated with the specified animation. The copy is propagated downstream, but only to the scoped animatable attributes. Once it's done its job, the copy is discarded, and the original transaction picks up where it left off.
So when the transaction reaches the child content, because the original transaction is unaffected by any of the intermediate animation view modifiers, there's no risk of accidental animations. A limited set of transaction APIs have been available since the first version of SwiftUI. Now we're introducing the capability to define custom transaction keys, so you can leverage the transaction dictionary to implicitly propagate your own update-specific data.
If you've ever declared a custom environment key, declaring a custom transaction key will be familiar. The pattern is to create a unique type that conforms to the TransactionKey protocol. The only requirement is to provide a defaultValue. Then declare a computed property as an extension on Transaction that reads and writes from the transaction dictionary using your key. Here, I've defined a boolean key to track whether, for the given update, the avatar was tapped or not. I'll decide which animation to use based on its value.
If the avatar was selected interactively, I'll scale it up or down using a more lively spring. But if the avatar was selected programmatically, I'll scale it using a more subdued spring. I can set a value in the transaction dictionary for the given update by wrapping withTransaction around my state change. This should seem familiar. withAnimation is just a thin wrapper around withTransaction.
The arguments passed to withTransaction are a keypath to a computed property on the Transaction and the value to set.
Transaction is unique among SwiftUI's implicit data-flow constructs because it's discarded at the end of every update. This means, unless explicitly set for the current update, every value in the transaction dictionary reverts to just being the default value for its key.
In the avatar view, when the tap gesture closure fires, avatarTapped is set to true for the current update.
The transaction also contains the default value for the animation key, which is nil.
The transaction propagates across the view hierarchy until it reaches the transaction modifier.
Here, the avatar view reads avatarTapped, and based on its value, sets the appropriate animation...
Which propagates down the view hierarchy.
This works pretty well, but just like earlier, it can result in accidental animations.
To give you finer-grained control over modifying the transaction, we're introducing two new variants of the transaction modifier. One lets you scope using a value argument. And the other lets you scope to a sub-hierarchy defined in a body closure. These variants mirror the scoped animation view modifiers covered earlier.
In this session, I explained SwiftUI's powerful animation primitives, Animatable, Animation, and Transaction.
As a next step, I recommend checking out two related sessions. "Animate with springs" provides more guidance on why and how to effectively use spring animations in your app. And "Wind your way through advanced animations in SwiftUI" introduces powerful new tools for constructing multi-step animations. I hope this content gives you a better understanding of how SwiftUI animation works and empowers you to more skillfully leverage animation in your app. Thanks. ♪ ♪