In this article, we're going to focus on tweening, non-interactive animations. Since iOS 15.0, SwiftUI also has support for keyframe animations via TimelineView
, but we'll treat those separately. Same for interactive animations.
This targets iOS 15.0 and up.
Join us
Tweening animations
If you already know how tweening animations work, you can safely skip this part. It's mostly a beginner-friendly opening plus some fun trivia.
Tweening animations create the illusion of motion by filling the intermediate frames between two keyframes. The idea originates from traditional, pen and paper animation where lead artists would draw the keyframes to define the rough movement of characters while assistants would fill in the missing frames to finish the animation. Most of the animations in SwiftUI are tweening animations. Every time you use .animation
or withAnimation()
you're using 100 years old technology. Well, sort of.
For this to work, the initial state of the view (first keyframe), the target state (second keyframe), and the timing function are all required.
Timing functions define the way the animation time progresses compared to the wall time. In SwiftUI these are included in the first parameter of .animation()
modifier and withAnimation(animation:)
(a bit confusingly called animation)
Example:
If you use .linear
the wall time will run 1:1 to the progress time. For .easeIn
, the progress
time will start slow (e.g. presenting 30% of the interpolation while the wall time reached 75% of
the duration) only to recover sharply in the end.
Most of the time SwiftUI figures out the properties to animate (position, size, color, in general, anything that you can represent as one or multiple numbers (or a tensor, if you will) and interpolates them from the initial to the target value. In some cases, it needs additional help (mostly via Animatable
, matchedGeometryEffect(id:in:)
or transitions)
Let's sum up:
- a tweening animation needs an initial frame, a target frame, and a timing function
- similarly, SwiftUI needs an initial view state, a target view state, and an
Animation
(i.e. a way to describe both the timing function and the duration:.easeIn
,.easeInOut
,.linear(duration:)
, etc.) - anything that has a numeric representation can be animated
- some things don't have a clear, natural numeric representation, in which case SwiftUI needs some help figuring out how to correctly interpolate between the states
The anatomy of a transaction
When the state changes SwiftUI takes a series of steps to update the view accordingly. All these steps share in common a context: the transaction.
Before even starting the update, you can explicitly specify a transaction by using withTransaction()
or withAnimation()
. When doing so, we say we have an explicit animation.
withAnimation(.linear) {
// view will be updated with an ad-hoc transaction
viewmodel.progress = 1
}
let transaction = Transaction(animation: .linear)
withTransaction(transaction) {
// view will be updated with the specified transaction
viewmodel.progress = 1
}
Alternatively, you can let SwiftUI create the transaction then selectively modify it just before the view updates are applied by adding an .animation()
or a .transaction()
modifier to the affected views. When doing so, we say we have an implicit animation.
view
.animation(.linear, value: viewmodel.progress)
// careful, this will instead add a timing function to the curent transaction
// making all state changes animated, not just the progress like above
view
.transaction {
$0.animation = .linear
}
By now you might be wondering why there are two APIs doing what seems to be the same thing. Truth is, .animation()
and withAnimation()
are mostly syntactic sugar for their transaction counterparts, except for .animation(animation:value:)
which keeps track of the value and transforms the transaction only if the value changes.
Transactions also allow you to mark a state update as continuous (using isContinuous
) or disable subsequent transformations with disablesAnimations
.
isContinuous
is useful when you want to have a different animation for a continuous transaction than for a regular transaction (which is pretty much every time):
view.gesture(
DragGesture()
.updating($progress) { value, state, transaction in
state = value.translation.height / 500
transaction.isContinuous = true
}
)
.transaction {
if $0.isContinuous {
// no animation if we drag the view
$0.animation = nil
}
else {
$0.animation = .easeInOut(duration: 1)
}
}
.onChange(of: progress) {
isSquare = $0 < 0.5
}
On the other hand, .disablesAnimations
stops all .animation()
view modifiers from adding animations to the transaction. In the example below, the opacity won't be animated because we disallowed the .animation(.linear, value: isVisible)
modifier to add any animation to the transaction.
VStack {
Text("Hello")
.opacity(isVisible ? 1 : 0)
Button(action: {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isVisible.toggle()
}
}) {
Text("Show")
}
}
.animation(.linear, value: isVisible)
Careful:
While this works fine with .animation()
it does not work with .transaction()
. This might be a
bug as of iOS 15.2, but might be on purpose as well. disablesAnimations
will not prevent
.transaction()
from adding a new animation to the current transaction.
Common transactions pitfalls
- When using
.animation(animation:value:)
it's important to understand that once the value parameter changes the current transaction will become animated regardless of whether you use the value or not.
struct UserView: View {
@State private var showDetails: Bool = false
@State private var showSocial: Bool = false
var body: some View {
VStack {
DetailsView()
.offset(x: 0,
y: showDetails ? 0 : -400)
SocialView()
.offset(x: 0,
y: showSocial ? 0 : -400)
Button(action: {
showDetails.toggle()
showSocial.toggle()
}) {
Text("Reveal all")
}
}
.animation(.linear, value: showDetails)
}
}
In the example above, SocialView
will be animated even if the animation is applied only on showDetails
change (notice social vs details) since SwiftUI creates one single transaction for all the changes inside the button's action closure.
- This takes us to the next topic: when transactions are not explicit, SwiftUI creates one transaction per run loop cycle, which practically means as long as your code is not async, all updates you make in a closure will be governed by the same transaction.
Generally speaking, this is something you want: it ensures all the resulting layout changes are taken into account at once. In some edge cases, you might want to have multiple transactions instead. To do so, simply use multiple explicit animations (alternatively, but not recommended you can use DispatchQueue.main.async
):
// first transaction
withAnimation {
showDetails.toggle()
}
// second transaction
withAnimation(.easeIn) {
showSocial.toggle()
}
This will create two transactions, each with its timing function.
- When using multiple
.animation()
modifiers the first and innermost always win. In both of the next examples, a linear animation will be applied.
// First wins
view
.animation(.linear, value: value)
.animation(.easeIn, value: value)
// Innermost wins
ZStack {
view.animation(.linear, value: value)
}
.animation(.easeIn, value: value)
Transitions
Now that we established how tweening animations and SwiftUI transactions work, let's see what happens when the interpolated values don't have a natural numerical representation.
First, let's take adding and removing a view from the view tree. This is a binary operation, the view is part of the view tree or is not. There's no intermediary state. A view can't be half part of a view tree.
The question is, what should SwiftUI interpolate when adding/removing a view. Transitions come with the answer. When setting a transition you let SwiftUI know how to interpolate the addition/removal of a view. By default, its opacity goes from 0 to 1 (on appear) and back to 0 (on disappear), but you can also interpolate its size, position, or even provide custom transitions.
Careful:
Unlike the .animation()
modifier, outermost .transition()
s take precedence in front of inner
ones. In the example below both DetailsView
and SocialView
will .slide
in and out as long
as we update showDetails
and showSocial
in the same transaction.
Exception:
In general, the above rule holds, except when using Group
as the container. It might be because
Group
is an inert container, or simply a bug.
VStack {
if showDetails {
VStack {
DetailsView()
if showSocial {
SocialView()
.transition(.opacity)
}
}
.transition(.slide)
}
Button(action: {
showDetails.toggle()
showSocial.toggle()
}) {
Text("Reveal all")
}
}
.animation(.linear, value: showDetails)
A word on .matchedGeometryEffect(id:in:)
and view identity. These two are crucial to some animations. I'm not going to talk about them in detail in this article, they both deserve an article of their own. They're especially important when animating view insertion and removal, helping SwiftUI to keep geometric (for .matchedGeometryEffect(id:in:)
) or identifier continuity between transitions.
The Animatable
protocol
As I mentioned earlier, anything that can be represented as a number can be animated. However, some things don't have a natural numeric representation.
Take the movement of a shape along a path. The path is described by a series of complex equations and there's no single intrinsic value you can interpolate to make the shape follow the path. You can however calculate the position of the object at any relative time, from start (0.0) to finish (1.0) using a bezier resolver (for example) boiling it down to only one numeric value, the progress along the path.
Views conforming to Animatable
protocol should provide these intermediate frames based on the animation progress (called animatableData
). It complements .transition()
helping SwiftUI animate things that could not be otherwise interpolated.
Conclusion
To sum up, fully controlling the animation stack in SwiftUI requires a good understanding of transactions, explicit and implicit animations, transitions, and the Animatable
protocol.
With these tools, you can tackle almost any animation out there.