Transactions and Animations · objc.io

0
13
Transactions and Animations · objc.io

In SwiftUI, there are many different ways to animate something on the screen. You can have implicit animations, explicit animations, animated bindings, transactions and even add animations to things like. FetchRequest.

Implicit animations are animations defined in the view tree. Consider e.g. following code. It animates the color of a circle between red and green:

								struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.red : Color.green)
            .frame(width: 50, height: 50)
            .animation(.default)
            .onTapGesture {
                green.toggle()
            }
    }
}

							

This animation style is called implicitly because any change of the sub-tree of .animation calls are implicitly animated. When you run this code as a Mac app, you will see a strange effect: at app launch, the position of the circle is also animated. This is because .animation(.default) will animate every time anything changes. We have avoided and warned against implicit animations for this reason: When your app gets big enough, these animations will inevitably happen when you do not want them to, and cause all sorts of strange effects. Fortunately, this kind of implicit animations from Xcode 13 have become obsolete.

There’s another kind of implicit animation there do work as expected. This animation is limited to animating only when a certain value changes. In our example above, we only want to animate at any one time green property changes. We can limit our animation by adding one value:

								struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.red : Color.green)
            .frame(width: 50, height: 50)
            .animation(.default, value: green)
            .onTapGesture {
                green.toggle()
            }
    }
}

							

It is our experience that these limited implicit animations work reliably and do not have any of the strange side effects that the unlimited implicit animations have.

You can also animate using explicit animations. With explicit animations you do not write .animation in your view tree, but instead you make your state changes within one withAnimation block:

								struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.red : Color.green)
            .frame(width: 50, height: 50)
            .onTapGesture {
                withAnimation(.default) {
                    green.toggle()
                }
            }
    }
}

							

When using explicit animations, SwiftUI will essentially take a snapshot of the view tree before state changes, a snapshot after state changes, and animate any changes in between. Explicit animations also have none of the problems that unlimited implicit animations have.

Sometimes, though, you end up with a mix of implicit and explicit animations. This can raise many questions: when you have both implicit and explicit animations, which ones take precedence? Can you somehow disable implicit animations when you already have an explicit animation? Or can you disable explicit animations for a specific part of the display tree?

To understand this, we need to understand transactions. In SwiftUI, each state change has an associated transaction. The transaction also contains all the current animation information. For example, when we write an explicit animation as above, what we are actually writing is this:

								withTransaction(Transaction(animation: .default)) {
    green.toggle()
}

							

When the body of the view is re-executed, this transaction is carried through the view tree. That fill will then be animated using the current transaction.

When we write an implicit animation, what we are actually doing is changing the transaction for the current subtree. In other words, when writing .animation(.easeInOut), you change the subtree transaction.animation to be .easeInOut.

You can confirm this with .transaction modifier, which allows you to print (and modify) the current transaction. If you run the following code, you will see that the inner view tree receives a modified transaction:

								Circle()
    .fill(green ? Color.red : Color.green)
    .frame(width: 50, height: 50)
    .transaction { print("inner", $0) }
    .animation(.easeInOut)
    .transaction { print("outer", $0) }

							

This answers our first question: the implicit animation takes precedence. When you have both implicit and explicit animations, the root transaction carries the explicit animation, but for the subtree with the implicit animation, the animation of the transaction is overwritten.

This brings us to our second question: is there a way to disable implicit animations when trying to create an explicit animation? And let me spoil the answer: yes! We can put a flag disablesAnimations to disable implicit animations:

								struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.red : Color.green)
            .frame(width: 50, height: 50)
            .animation(.easeInOut, value: green)
            .onTapGesture {
                var t = Transaction(animation: .linear(duration: 2))
                t.disablesAnimations = true
                withTransaction(t) {
                    green.toggle()
                }
            }
    }
}

							

When you run the above code, you will see that the animation of the transaction takes precedence over the implicit animation. The flag disablesAnimations has a confusing name: it does not actually disable animations: it only disables implicit animations.

To understand what is happening, let’s try to re-implement .animation using .transaction. We set the animation of the current transaction to the new animation, unless it disablesAnimations flag is set:

								extension View {
    func _animation(_ animation: Animation?) -> some View {
        transaction {
            guard !$0.disablesAnimations else { return }
            $0.animation = animation
        }
    }
}

							

Note: An interesting side effect of this is that you can also disable any .animation(nil) calls by setting disablesAnimations property on the trade. Note that you can also re-implement .animation(_:value:) uses the same technique but it is a bit more work as you have to remember the previous value.

Let’s look at our last question: can you somehow disable or override explicit animations for a subtree? The answer is “yes”, but not by using .animation. Instead, we need to change the current transaction:

								extension View {
    func forceAnimation(animation: Animation?) -> some View {
        transaction { $0.animation = animation }
    }
}

							

For me personally, transactions were always a bit of a mystery. Someone in our SwiftUI Workshop asked about what happens when you have both implicit and explicit animations, and that’s how I started to take a closer look at this. Now that I think I understand them, I think transactions are the underlying primitive, and both withAnimation and .animation is built upstairs withTransaction and .transaction.

If you are interested in understanding how SwiftUI works, you should read our book Thinking in SwiftUI, watch our SwiftUI videos on Swift Talk, or better yet: attend one of our workshops.

LEAVE A REPLY

Please enter your comment!
Please enter your name here