This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We show how animations in SwiftUI are implemented using transactions.

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 Transactions 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 Transactions 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.

Resources

  • Sample Code

    Written in Swift 5.5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

166 Episodes · 57h46min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes