00:06 In a recent workshop, someone asked what would happen if an implicit
animation (which is added to a view using the animation
modifier) is combined
with an explicit animation (created by withAnimation
). Which of these
animations takes precedence? While experimenting to figure out the answer, we
learned a lot about what goes on under the hood of these APIs.
Setting Up
00:37 Let's build a small view for playing with animations. We create a
pill-shaped badge with a number, and below it, we add a button that increments
the number:
struct ContentView: View {
@State var value = 0
var body: some View {
VStack {
badge
Button("Increment") {
value += 1
}
}
.padding(50)
}
@ViewBuilder var badge: some View {
Text("\(value)")
.fixedSize()
.padding(.horizontal)
.background(Capsule().fill(Color.accentColor))
}
}
01:54 The badge size changes slightly with each increment of value
. By
using a font with monospaced digits, the badge only grows in size when value
goes from 9 to 10, for example:
struct ContentView: View {
@ViewBuilder var badge: some View {
Text("\(value)")
.monospacedDigit()
.fixedSize()
.padding(.horizontal)
.background(Capsule().fill(Color.accentColor))
}
}
02:17 For the animation, we can use a default scale transition. To
trigger this transition, we set the view's id
to value
so that SwiftUI sees
the badge as a new view whenever the number changes:
struct ContentView: View {
@ViewBuilder var badge: some View {
Text("\(value)")
.monospacedDigit()
.fixedSize()
.padding(.horizontal)
.background(Capsule().fill(Color.accentColor))
.transition(.scale)
.id(value)
}
}
03:16 An Animation
defines the curve that should be used for
transitions, so we specify that the .default
animation should be implicitly
applied to the badge view when the value
property changes:
struct ContentView: View {
@State var value = 0
var body: some View {
VStack {
badge
.animation(.default, value: value)
Button("Increment") {
value += 1
}
}
.padding(50)
}
}
03:35 Not much happens yet when we run this. This is an issue we
sometimes encounter when we apply a transition to a plain view. Wrapping the
view in a stack can help make its transition visible:
struct ContentView: View {
@ViewBuilder var badge: some View {
VStack {
Text("\(value)")
.monospacedDigit()
.fixedSize()
.padding(.horizontal)
.background(Capsule().fill(Color.accentColor))
.transition(.scale)
.id(value)
}
}
}
04:14 And to get a closer look at the transition, we add a toggle to
switch between normal and slower animations:
struct ContentView: View {
@State var value = 0
@State var slow = false
var body: some View {
VStack {
badge
.animation(.default.speed(slow ? 0.1 : 1), value: value)
Button("Increment") {
value += 1
}
Toggle("Slow", isOn: $slow)
}
.padding(50)
}
}
04:51 When we click the button, the badge scales down, and another one
scales back up, showing the new number. But the text only appears after the
badge is fully scaled up. We're not entirely sure why this happens — it probably
should just work as is. But we can fix this weird behavior by wrapping the view
in a drawing group, which composites a view stack into a single image. With this
in place, the number is visible while the badge scales up:
struct ContentView: View {
@ViewBuilder var badge: some View {
VStack {
Text("\(value)")
.monospacedDigit()
.fixedSize()
.padding(.horizontal)
.background(Capsule().fill(Color.accentColor))
.drawingGroup()
.transition(.scale)
.id(value)
}
}
}
Transactions
05:53 We're now ready to experiment with animations. Let's insert
transaction
calls before and after the animation
modifier and print the
description of both Transaction
s to the console:
struct ContentView: View {
@State var value = 0
@State var slow = false
var body: some View {
VStack {
badge
.transaction { print("Inner", $0, value) }
.animation(.default.speed(slow ? 0.1 : 1), value: value)
.transaction { print("Outer", $0, value) }
Button("Increment") {
value += 1
}
Toggle("Slow", isOn: $slow)
}
.padding(50)
}
}
06:43 When the app launches, the outer and inner Transaction
s are both
empty:
Outer Transaction(plist: []) 0
Inner Transaction(plist: []) 0
06:55 After clicking the button to increment the badge's number once,
the outer Transaction
is still empty, but the inner Transaction
— as it
exists after the animation
call — includes the implicit animation:
Outer Transaction(plist: []) 1
Inner Transaction(plist: [Key<AnimationKey> = Optional(AnyAnimator(SwiftUI.SpeedAnimation<SwiftUI.BezierAnimation>(animation: SwiftUI.BezierAnimation(duration: 0.35, curve: SwiftUI.(unknown context at $1a7738ec0).BezierTimingCurve(ax: 0.52, bx: -0.78, cx: 1.26, ay: -2.0, by: 3.0, cy: 0.0)), speed: 1.0)))]) 1
07:14 Transactions are like vehicles for animations: they propagate a
state change, including the Animation
associated with it, down the view tree.
07:42 The animation
modifier seems to pass whatever we specify into a
Transaction
. We can verify this by duplicating the badge view, applying the
built-in modifier to one view, and applying a custom one to the other. We skip
the value
argument because we don't want to reimplement it in our custom
modifier:
struct ContentView: View {
@State var value = 0
@State var slow = false
var body: some View {
VStack {
HStack {
badge
.myAnimation(.default.speed(slow ? 0.1 : 1))
badge
.transaction { print("Inner", $0, value) }
.animation(.default.speed(slow ? 0.1 : 1))
.transaction { print("Outer", $0, value) }
}
Button("Increment") {
value += 1
}
Toggle("Slow", isOn: $slow)
}
.padding(50)
}
}
08:38 In myAnimation
, we call transaction
on the view, and we store
the passed-in Animation
in the Transaction
:
extension View {
func myAnimation(_ curve: Animation) -> some View {
transaction { t in
t.animation = curve
}
}
}
09:17 The variant of the animation
modifier without the value
argument is deprecated because it has a lot of unexpected artifacts, such as
animating in from a random spot when the view first appears. But we'll ignore
this for now, because we don't want to reimplement the version of animation
with value
.
09:42 The two badges now show identical behavior, suggesting
transactions are the underlying mechanism of animations.
Combining Implicit and Explicit Animations
10:04 Let's now dive into the original motivation for this episode and
find out what happens if we define an explicit animation for the state change by
using withAnimation
in addition to the view's implicit animation:
struct ContentView: View {
@State var value = 0
@State var slow = false
var body: some View {
VStack {
HStack {
badge
.myAnimation(.default.speed(slow ? 0.1 : 1))
badge
.transaction { print("Inner", $0, value) }
.animation(.default.speed(slow ? 0.1 : 1))
.transaction { print("Outer", $0, value) }
}
Button("Increment") {
withAnimation(.linear(duration: 2)) {
value += 1
}
}
Toggle("Slow", isOn: $slow)
}
.padding(50)
}
}
10:27 The implicit animation overrides the explicit animation in both
versions of the badge view. In other words: when we call animation
, we replace
the animation stored in the transaction.
11:10 Looking at the transactions printed to the console, we can see
that the outer transaction holds the explicit animation that's 2 seconds long,
and the inner transaction contains the implicit animation of 0.35 seconds that's
defined in the view:
Outer Transaction(plist: [Key<AnimationKey> = Optional(AnyAnimator(SwiftUI.BezierAnimation(duration: 2.0, curve: SwiftUI.(unknown context at $1a7738ec0).BezierTimingCurve(ax: -2.0, bx: 3.0, cx: 0.0, ay: -2.0, by: 3.0, cy: 0.0))))]) 1
Inner Transaction(plist: [Key<AnimationKey> = Optional(AnyAnimator(SwiftUI.SpeedAnimation<SwiftUI.BezierAnimation>(animation: SwiftUI.BezierAnimation(duration: 0.35, curve: SwiftUI.(unknown context at $1a7738ec0).BezierTimingCurve(ax: 0.52, bx: -0.78, cx: 1.26, ay: -2.0, by: 3.0, cy: 0.0)), speed: 1.0))), Key<AnimationKey> = Optional(AnyAnimator(SwiftUI.BezierAnimation(duration: 2.0, curve: SwiftUI.(unknown context at $1a7738ec0).BezierTimingCurve(ax: -2.0, bx: 3.0, cx: 0.0, ay: -2.0, by: 3.0, cy: 0.0))))]) 1
11:44 Instead of creating an explicit animation using withAnimation
,
we can directly create a Transaction
and pass it to withTransaction
:
Button("Increment") {
withTransaction(Transaction(animation: .linear(duration: 2))) {
value += 1
}
}
12:21 This has the exact same effect, which makes us believe
withAnimation
is just shorthand for withTransaction
.
Disabling Implicit Animations
12:33 But having access to the transaction means we have more control.
We can update a property on the transaction to disable any implicit animations
defined on the view:
Button("Increment") {
var t = Transaction(animation: .linear(duration: 2))
t.disablesAnimations = true
withTransaction(t) {
value += 1
}
}
13:29 The badge on the left — the one using our myAnimation
implementation — still shows the implicit animation. But the right-hand side
badge with the built-in implicit animation now uses the explicit, linear
animation.
13:56 By setting disablesAnimation
to true
, the transaction blocks
any implicit animations from being set further down the view tree.
14:40 Let's reimplement this in myAnimation
. We check the
transaction's disablesAnimations
property, and only if it's false do we store
the animation in the transaction:
extension View {
func myAnimation(_ curve: Animation) -> some View {
transaction { t in
if !t.disablesAnimations {
t.animation = curve
}
}
}
}
15:10 We find the name disablesAnimations
a little bit misleading,
because it doesn't disable the animations that are already added to the view
tree. It just prevents new animations from being passed into the transaction.
Discussion
15:24 In summary: withAnimation
and animation
can be implemented in
terms of transactions, which are the underlying mechanisms of animations.
Knowing this, we can disable the implicit animations defined by a component. And
the other way around: if we're writing a component and we don't want the
animations to be disabled, we can force them by storing them directly in a
Transaction
.
16:27 In the past, we've recommended against implicit animations. But
now that we understand the system better, we think implicit animations with a
value
are fine to use. They allow us to specify an Animation
within a
component, knowing the animation can still be controlled from the outside.
17:02 Using onChange(of:)
, we should also be able to implement the
version of animation
that takes a value
argument, but we'll leave this as an
exercise for the viewer.