Demystifying SwiftUI Animation: A Comprehensive Guide

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Most beginners are amazed at the ease with which SwiftUI can achieve various animation effects, but after a period of use, they will find that SwiftUI’s animation is not as easy to control as it seems. Developers often face questions such as: how to animate, how to animate, what can be animated, why not animate, why animate like this, and how to prevent animation, etc. The main reason for these problems is the lack of in-depth understanding of SwiftUI’s animation processing logic. This article will try to introduce the animation mechanism of SwiftUI to help you better learn and master SwiftUI’s animation and create satisfactory interactive effects.

Before reading this article, readers should have experience in programming animations in SwiftUI or have a certain understanding of the basic usage of SwiftUI animation. You can get the full code of this article here.

What are animations in SwiftUI?

SwiftUI uses a declarative syntax to describe UI presentation in different states, including animations. According to the official documentation, animations in SwiftUI are defined as creating smooth transitions from one state to another.

In SwiftUI, we cannot command a view to move from one position to another. To achieve this effect, we need to declare the position of the view in state A and state B. When the state changes from A to B, SwiftUI will use the specified algorithm function to provide the data required for generating smooth transitions for specific components (if the component is animatable).

To implement an animation in SwiftUI, we need the following three elements:

  • A timing curve algorithm function
  • A declaration that associates the state (specific dependency) with the timing curve function
  • An animatable component that depends on the state (specific dependency)

animationThreeElements-16274701.png

Confusing Animation Naming

Timing Curve Functions

SwiftUI has given a confusing name to timing curve algorithm functions - Animation. Perhaps naming it Timing Curve or Animation Curve would be more appropriate (like CAMediaTimingFunction in Core Animation).

This function defines the rhythm of the animation as a timing curve, transforming the starting data to the ending data along the timing curve.

Swift
Text("Hello world")
    .animation(.linear(duration: 0.3), value: startAnimation)
    .opacity(startAnimation ? 0 : 1)

The time series curve function (Animation) linear(duration:0.3) means that the data will undergo linear transformation (in this example, from 0 to 1) in 0.3 seconds.

https://cdn.fatbobman.com/linear_value_sheet.png

https://cdn.fatbobman.com/image-20220504111032123.png

Values corresponding to the timing curve function (Animation) easeInOut(duration:0.3):

https://cdn.fatbobman.com/easeInOut_value_sheet.png

https://cdn.fatbobman.com/image-20220504110821144.png

The role of the time series function is only to perform interpolation and transformation on data, and how to use the interpolated data is the responsibility of the animatable component.

VectorArithmetic

Only data types that conform to the VectorArithmetic protocol can be applied to time series functions. SwiftUI provides us with several out-of-the-box data types, such as Float, Double, CGFloat, etc.

Swift
Text("Hello world")
    .animation(.linear(duration: 0.3), value: startAnimation)
    .opacity(startAnimation ? 0 : 1) // Double type, conforms to VectorArithmetic protocol

Other data types can also provide animation data for animatable components by implementing the requirements of the VectorArithmetic protocol.

Majid’s The magic of Animatable values in SwiftUI shows how to make custom types conform to the VectorArithmetic protocol.

Associating Time-Series Curve Functions with States

Only by associating time-series curve functions (Animation) with one or more dependencies in some form, SwiftUI will generate interpolation data for animations when the state (the associated dependency) changes. The ways of association include the view modifier “animation” or the global function “withAnimation”.

The animation anomalies in SwiftUI (not meeting developers’ expectations) are often related to incorrect association methods or association positions.

Place the animation modifier in the correct position

Code 1:

Swift
@State var animated = false

VStack {
    Text("Hello world")
        .offset(x: animated ? 200 : 0)
        .animation(.easeInOut, value: animated) // The scope of the animation modifier is the current view hierarchy and its subviews

    Text("Fat")
        .offset(x: animated ? 200 : 0)
}

https://cdn.fatbobman.com/single_animation_2022-05-04_14.08.25.2022-05-04%2014_09_34.gif

Code 2:

Swift
VStack {
    Text("Hello world")
        .offset(x: animated ? 200 : 0)

    Text("Fat")
        .offset(x: animated ? 200 : 0)
}
.animation(.easeInOut, value: animated)

https://cdn.fatbobman.com/both_animation_2022-05-04_14.05.54.2022-05-04%2014_06_58.gif

The difference in the position of animation in the above two code snippets leads to differences in the behavior of the animation when its associated dependency (animated) changes. In code snippet one, only “Hello world” produces a smooth animation, while in code snippet two, both “Hello world” and “Fat” produce a smooth animation.

Like all SwiftUI view modifiers, the position of the modifier in the code determines its scope and target. The animation modifier only affects the view hierarchy it is in and its child nodes.

Neither of the above code snippets is right or wrong. In some scenarios, we may need all content dependent on a certain item (state) to produce a smooth animation when it changes (like in code snippet two), while in other scenarios, we may only need some content to produce a smooth animation (like in code snippet one). By adjusting the position of animation, we can achieve the desired effect.

Using animation version with specific dependencies only

SwiftUI provides two versions of the animation modifier:

Swift
// Version 1, without specifying specific dependencies
func animation(_ animation: Animation?) -> some View

// Version 2, specifying specific dependencies, as used in the previous code
func animation<V>(_ animation: Animation?, value: V) -> some View where V : Equatable

The first way has been deprecated in SwiftUI 3.0 and was one of the culprits causing animation issues in earlier versions of SwiftUI. This version of animation associates with all dependencies of the view hierarchy and its sub-nodes. Any change in dependencies in the view or its sub-nodes will satisfy the conditions for interpolation calculation and the animated data will be propagated to all animatable components within the scope (the view and its sub-nodes).

For example, because the animation in the code below does not specify a specific dependency, when the button is clicked, both the position and color will produce a smooth animation.

Swift
struct Demo2: View {
    @State var x: CGFloat = 0
    @State var red = false
    var body: some View {
        VStack {
            Spacer()
            Circle()
                .fill(red ? .red : .blue)
                .frame(width: 30, height: 30)
                .offset(x: x)
                .animation(.easeInOut(duration: 1)) // associates both x and red dependencies
//                .animation(.easeInOut(duration: 1), value: x)  // recommended to use separate associations
//                .animation(.easeInOut(duration: 1), value: red)

            Spacer()
            Button("Animate") {  // changes the values of two dependencies in the closure
                if x == 0 {
                    x = 100
                } else {
                    x = 0
                }
                red.toggle()
            }
        }
        .frame(width: 500, height: 300)
    }
}

By using the animation<V>(_ animation: Animation?, value: V) version, we can make only one of the position or color produce a smooth animation. When modifying multiple dependencies at once, animation(_ animation: Animation?) is prone to unnecessary animations, which is the main reason it has been deprecated.

In this example, using withAnimation can achieve the same effect by modifying specific dependencies within the closure of withAnimation to achieve separate animation control.

Swift
struct Demo2: View {
    @State var x: CGFloat = 0
    @State var red = false
    var body: some View {
        VStack {
            Spacer()

            Circle()
                .fill(red ? .red : .blue)
                .frame(width: 30, height: 30)
                .offset(x: x)
            Spacer()
            Button("Animate") {
                if x == 0 {
                    x = 100
                } else {
                    x = 0
                }
                withAnimation(.easeInOut(duration: 1)) { // Only color will transition smoothly
                    red.toggle()
                }
            }
        }
        .frame(width: 500, height: 300)
    }
}

Associate different timing curve functions with different dependencies

Observant friends may notice that in the previous text, when correlating time sequence curve functions, I used the term “dependency” instead of “state”. This is because the state of the view is the overall presentation of all its dependencies. witAnimation allows us to set different time sequence curve functions for different dependencies of the same animatable widget.

Swift
struct Demo4: View {
    @State var x: CGFloat = 0
    @State var y: CGFloat = 0
    var body: some View {
        VStack {
            Spacer()
            Circle()
                .fill(.orange)
                .frame(width: 30, height: 30)
                .offset(x: x, y: y) // x and y are associated with different time sequence curve functions
            Spacer()
            Button("Animate") {
                withAnimation(.linear) {
                    if x == 0 { 
                        x = 100 
                    } 
                    else { 
                        x = 0 
                    }
                }
                withAnimation(.easeInOut) {
                    if y == 0 { y = 100 } else { y = 0 }
                }
            }
        }
        .frame(width: 500, height: 500)
    }
}

https://cdn.fatbobman.com/dual_timing_function_2022-05-04_15.25.59.2022-05-04%2015_27_18.gif

Because the x and y in offset(x: x, y: y) are associated with different timing curve functions through withAnimation, the motion of the horizontal and vertical axes is different during the animation (x is linear, y is eased in and out).

Currently, animation<V>(_ animation: Animation?, value: V) does not support associating different timing curve functions with different dependencies of the same animatable component.

In addition to associating different types of timing curve functions, SwiftUI also allows associating timing curve functions with different durations. When different dependencies of the same animatable component are associated with functions of different durations (with different durations or repeatForever enabled), the interpolation calculation logic will become more complex, and different combinations will have different results. Use with caution.

Swift
Button("Animate") {
    withAnimation(.linear) {
        if x == 0 { x = 100 } else { x = 0 }
    }
    withAnimation(.easeInOut(duration: 1.5)) {
        if y == 0 { y = 100 } else { y = 0 }
    }
}

https://cdn.fatbobman.com/different_duration_2022-05-09_12.44.24.2022-05-09%2012_45_01.gif

Use withAnimation with caution

In SwiftUI, when there is no animation<V>(_ animation: Animation?, value: V) modifier provided (associated with specific dependent properties), withAnimation may be a better choice than animation(_ animation: Animation?). At least it can explicitly associate specific dependent properties with timing curve functions.

However, now unless necessary (such as the need to associate different timing curve functions), animation<V>(_ animation: Animation?, value: V) should be preferred. This is because although withAnimation can specify dependent properties, it lacks the code location dimension of animation(_ animation: Animation?, value: V). withAnimation will affect all views associated with the dependent properties in the display. For example, it is difficult to achieve the effect of code one with withAnimation.

Additionally, it should be noted that when using withAnimation, the dependencies must be explicitly included in the closure, otherwise withAnimation will not work. For example:

Swift
struct Demo3: View {
    @State var items = (0...3).map { $0 }
    var body: some View {
        VStack {
            Button("In withAnimation") {
                withAnimation(.easeInOut) {
                    items.append(Int.random(in: 0...1000))
                }
            }
            Button("Not in withAnimation") { // Using the Array extension method
                items.appendWithAnimation(Int.random(in: 0...1000), .easeInOut)
            }
            List {
                ForEach(items, id: \\.self) { item in
                    Text("\\(item)")
                }
            }
            .frame(width: 500, height: 300)
        }
    }
}

extension Array {
    mutating func appendWithAnimation(_ newElement: Element, _ animation: Animation?) {
        withAnimation(animation) {
            append(newElement)
        }
    }
}

Although withAnimation is used in the Array extension method appendWithAnimation, the SwiftUI animation mechanism is not activated because the closure of withAnimation does not include specific dependencies.

Make your view elements animatable

Associating timing curve functions with specific dependencies only completes the step of setting animation activation conditions (when specific dependencies change) and specifying interpolation algorithms. As for how to use this animation data (interpolation data) to generate animations, it is determined by the animatable components associated with specific dependencies.

By adhering to the Animatable protocol, View or ViewModifier can acquire the ability to obtain animation data (AnimatableModifier has been deprecated). Many of SwiftUI’s official components have already met this protocol, such as offset, frame, opacity, fill, etc.

The requirements of the Animatable protocol are very simple, just implement a computed property animatableData.

Swift
public protocol Animatable {

    /// The type defining the data to animate.
    associatedtype AnimatableData : VectorArithmetic

    /// The data to animate.
    var animatableData: Self.AnimatableData { get set }
}

Please note that the type of animatableData specified in the protocol must conform to the VectorArithmetic protocol. This is because only types that conform to the VectorArithmetic protocol can be interpolated by timing curve functions.

When the dependencies associated with an animatable component change, SwiftUI calculates the interpolation using the specified timing curve function and continues to call the animatableData property of the animatable component associated with the dependency.

Swift
struct AnimationDataMonitorView: View, Animatable {
    static var timestamp = Date()
    var number: Double
    var animatableData: Double { // When rendering, SwiftUI detects that this view is Animatable and continues to call animableData based on the values provided by the timing curve function after the state has changed.
        get { number }
        set { number = newValue }
    }

    var body: some View {
        let duration = Date().timeIntervalSince(Self.timestamp).formatted(.number.precision(.fractionLength(2)))
        let currentNumber = number.formatted(.number.precision(.fractionLength(2)))
        let _ = print(duration, currentNumber, separator: ",")

        Text(number, format: .number.precision(.fractionLength(3)))
    }
}

struct Demo: View {
    @State var startAnimation = false
    var body: some View {
        VStack {
            AnimationDataMonitorView(number: startAnimation ? 1 : 0) // Declare the two states
                .animation(.linear(duration: 0.3), value: startAnimation) // Associate dependencies and timing curve functions
            Button("Show Data") {
                AnimationDataMonitorView.timestamp = Date()
                startAnimation.toggle() // Change dependencies
            }
        }
        .frame(width: 300, height: 300)
    }
}

The above code clearly shows this process.

Declaration Process:

  • Specify the timing curve function - linear
  • Associate the dependency startAnimation with linear
  • AnimationDataMonitorView (animatable component) conforms to Animatable and depends on startAnimation

Animation Process:

  • Click the button to change the value of dependency startAnimation
  • SwiftUI immediately completes the change of startAnimation value (the change of dependency value occurs before the animation starts, such as in this example, true will immediately become false)
  • SwiftUI finds that AnimationDataMonitorView conforms to the Animatable protocol and uses linear for interpolation calculation
  • SwiftUI continuously uses the calculation result of linear to set the animatableData property of AnimationDataMonitorView according to the device’s refresh rate (60 fps/sec or 120 fps/sec), and evaluates and renders the body of AnimationDataMonitorView

By setting print statements in the body, we can see the interpolation data at different time points:

https://cdn.fatbobman.com/animatable_data_demo_2022-05-04_17.32.01.2022-05-04%2017_34_12.gif

The table showing the changes in the numerical values of the timeline function in the previous text is generated by this code.

Here are some recommended blog posts introducing the usage of Animatable:

When there are multiple mutable dependencies for the animatable element, animatableData should be set to the AnimatablePair type so that SwiftUI can pass animation interpolation data that belongs to different dependencies.

The AnimatablePair type conforms to the VectorArithmetic protocol and requires that the wrapped numerical types also conform to the VectorArithmetic protocol.

The following code demonstrates the usage of AnimatablePair and how to view interpolated data from two different timing curve functions:

Swift
struct AnimationDataMonitorView: View, Animatable {
    static var timestamp = Date()
    var number1: Double // will change
    let prefix: String
    var number2: Double // will change

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(number1, number2) }
        set {
            number1 = newValue.first
            number2 = newValue.second
        }
    }

    var body: some View {
        let duration = Date().timeIntervalSince(Self.timestamp).formatted(.number.precision(.fractionLength(2)))
        let currentNumber1 = number1.formatted(.number.precision(.fractionLength(2)))
        let currentNumber2 = number2.formatted(.number.precision(.fractionLength(2)))
        let _ = print(duration, currentNumber1, currentNumber2, separator: ",")

        HStack {
            Text(prefix)
                .foregroundColor(.green)
            Text(number1, format: .number.precision(.fractionLength(3)))
                .foregroundColor(.red)
            Text(number2, format: .number.precision(.fractionLength(3)))
                .foregroundColor(.blue)
        }
    }
}

struct Demo: View {
    @State var startNumber1 = false
    @State var startNumber2 = false
    var body: some View {
        VStack {
            AnimationDataMonitorView(
                number1: startNumber1 ? 1 : 0,
                prefix: "Hi:",
                number2: startNumber2 ? 1 : 0
            )
            Button("Animate") {
                AnimationDataMonitorView.timestamp = Date()
                withAnimation(.linear) {
                    startNumber1.toggle()
                }
                withAnimation(.easeInOut) {
                    startNumber2.toggle()
                }
            }
        }
        .frame(width: 300, height: 300)
    }
}

https://cdn.fatbobman.com/animatable_dual_data_demo_2022-05-04_18.17.39.2022-05-04%2018_18_51.gif

SwiftUI is very intelligent when passing interpolated data, and only passes the dependent variables that have changed to the animatable element through animatableData. For example, in the above code, the parameter prefix does not change, so it will be automatically skipped when synthesizing the AnimatablePair data, only number1 and number2 will be synthesized.

When more parameters need to be passed, the AnimatablePair type can be nested, such as:

Swift
AnimatablePair<CGFloat, AnimatablePair<Float, AnimatablePair<Double, CGFloat>>>
// newValue.second.second.first.

Using Transactions for More Precise Control

In SwiftUI, the process of associating a timeline function with a state can be described in the official language as declaring a transaction for the view. Transactions provide a more flexible way of setting curve function types, animation switches, and temporary state flags.

Both the modifier animation and the global function withAnimation are actually shortcuts for declaring a transaction in the view, corresponding internally to transaction and withTransaction, respectively.

For example, withAnimation actually corresponds to:

Swift
withAnimation(.easeInOut){
    show.toggle()
}
// Corresponds to
let transaction = Transaction(animation: .easeInOut)
withTransaction(transaction) {
    show.toggle()
}

animation(_ animation: Animation?) is also implemented through Transaction:

Swift
// Code from swiftinterface
extension SwiftUI.View {
    @_disfavoredOverload @inlinable public func animation(_ animation: SwiftUI.Animation?) -> some SwiftUI.View {
        return transaction { t in
            if !t.disablesAnimations {
                t.animation = animation
            }
        }
    }
}

The disablesAnimations and isContinuous provided by Transaction can help developers better control animations, for example:

  • Dynamically select the associated timeline curve function
Swift
Text("Hi")
    .offset(x: animated ? 100 : 0)
    .transaction {
        if position < 0 || position > 100 {
            $0.animation = .easeInOut
        } else {
            $0.animation = .linear
        }
    }

The scope of transaction is the same as that of animation without specifying a specific dependent version, and it does not have the ability to be associated with a specific dependent item.

Swift
// It does not mean that only x is associated. Changes in other dependent items within the scope of the transaction will also produce animations.
.transaction {
    if x == 0 {
        $0.animation = .linear
    } else {
        $0.animation = nil
    }
}

// Equivalent to
.animation(x == 0 ? .linear : nil)
  • disablesAnimations
Swift
struct Demo: View {
    @State var position: CGFloat = 40
    var body: some View {
        VStack {
            Text("Hi")
                .offset(x: position, y: position)
                .animation(.easeInOut, value: position)

            Slider(value: $position, in: 0...150)
            Button("Animate") {
                var transaction = Transaction() // If the timeline curve function is not specified, the original setting will be retained (in this case, easeInOut).
                if position < 100 { transaction.disablesAnimations = true }
                withTransaction(transaction) { // withTransaction can disable the timeline curve function of the original transaction (associated with the animation), but cannot shield the timeline curve function associated with the transaction.
                    position = 0
                }
            }
        }
        .frame(width: 400, height: 500)
    }
}

withTransaction (disablesAnimations is set to disable animation) + animation<V>(_ animation: Animation?, value: V) is a more mature combination.

  • isContinuous
Swift
struct Demo: View {
    @GestureState var position: CGPoint = .zero
    var body: some View {
        VStack {
            Circle()
                .fill(.orange)
                .frame(width: 30, height: 50)
                .offset(x: position.x, y: position.y)
                .transaction {
                    if $0.isContinuous {
                        $0.animation = nil // Do not set timing function when dragging
                    } else {
                        $0.animation = .easeInOut(duration: 1)
                    }
                }
                .gesture(
                    DragGesture()
                        .updating($position, body: { current, state, transaction in
                            state = .init(x: current.translation.width, y: current.translation.height)
                            transaction.isContinuous = true // Set flag while dragging
                        })
                )
        }
        .frame(width: 400, height: 500)
    }
}

https://cdn.fatbobman.com/isContinuous_2022-05-06%2011.26.20.2022-05-06%2011_27_42.gif

According to the official documentation, some controls like Slider will automatically set isContinuous during dragging, but it doesn’t match the description in reality. However, we can use it to set temporary states in our code.

In addition, in some scenarios, we can use Transaction to get or set animation-related information for things like:

  • UIViewRepresentableContext
  • AsyncImage
  • GestureState
  • Binding, etc.

For example, setting Transaction for Binding:

Swift
struct Demo: View {
    @State var animated = false
    let animation: Animation?

    var animatedBinding: Binding<Bool> { // Generating Binding type containing specified Transaction
        let transaction = Transaction(animation: animation)
        return $animated.transaction(transaction)
    }

    var body: some View {
        VStack {
            Text("Hi")
                .offset(x: animated ? 100 : 0)

            Toggle("Animated", isOn: animatedBinding) // Automatically generates animation effect when clicked
        }
        .frame(width: 400, height: 500)
    }
}

PlaygroundPage.current.setLiveView(Demo(animation: .easeInOut))

https://cdn.fatbobman.com/binding_transaction_2022-05-06_11.33.10.2022-05-06%2011_34_38.gif

More Notes on Timing Curve Functions and State Relationships

  • SwiftUI only uses the declaration of the closest associated timing curve function and dependency for animatable components.
Swift
Circle()
    .fill(red ? .red : .blue)
    .animation(.easeInOut(duration: 1), value: red)  // use this
    .animation(.linear(duration: 3), value: red)
  • The timing curve function specified in withAnimation (withTransaction) cannot change the associated function in animation.
Swift
Circle()
    .fill(red ? .red : .blue)
    .animation(.easeInOut(duration: 1), value: red)  // use this

Button("Change red"){
    withAnimation(.linear(duration:3)){  // maximum scope, meaning far from animatable component
        red.toggle()
    }
}
  • Either animation or withAnimation should be chosen.

  • withTransaction can suppress the timing curve function associated with animation

    By setting disablesAnimations, the original timing curve function in the transaction can be disabled (cannot be changed), see the previous section for details

  • Adopt an appropriate way to dynamically set the timing curve function

Swift
// Method 1, associated with specific dependencies, suitable for only two situations
.animation(red ? .linear : .easeIn , value: red)

// Method 2, can handle more logic, but not associated with specific dependencies
.transaction{
    switch status{
        case .one:
            $0.animation = .linear
        case .two:
            $0.animation = .easeIn
        case .three:
            $0.animation = nil
    }
}

// Method 3, supports complex logic and is associated with specific status
var animation:Animation?{
    // Even if multiple different dependencies appear in the closure, it will not affect the characteristic of animation being only associated with the specified dependency
    switch status{
        case .one:
            $0.animation = .linear
        case .two:
            $0.animation = .easeIn
        case .three:
            $0.animation = nil
    }
}

.animation(animation , value: status)

// Method 4, with a large scope
var animation:Animation?{
    switch status{
        case .one:
            $0.animation = .linear
        case .two:
            $0.animation = .easeIn
        case .three:
            $0.animation = nil
    }
}

withAnimation(animation){
    ...
}

// Method 5, with a large scope
var animation:Animation?{
    switch status{
        case .one:
            $0.animation = .linear
        case .two:
            $0.animation = .easeIn
        case .three:
            $0.animation = nil
    }
}
var transaction = Transaction(animation:animation)
withTransaction(transaction){
    ...
}

// Etc.

Transition

What is Transition

The transition type (AnyTransition) in SwiftUI is a re-packaging of animatable components. When the change of state causes a change in the branch of the view tree, SwiftUI will use its wrapped animatable components to animate the view.

By setting disablesAnimations, the original timing curve function (which cannot be changed) in the transaction can be disabled. See the previous section for details.

Using transitions also requires satisfying the three elements of SwiftUI animation.

Swift
struct TransitionDemo: View {
    @State var show = true
    var body: some View {
        VStack {
            Spacer()
            Text("Hello")
            if show {
                Text("World")
                    .transition(.slide) // animatable view (wrapped in)
            }
            Spacer()
            Button(show ? "Hide" : "Show") {
                show.toggle()
            }
        }
        .animation(.easeInOut(duration:3), value: show) // create dependency association, set timing curve function
        .frame(width: 300, height: 300)
    }
}

Therefore, like all SwiftUI animation elements, transitions also support interruptible animations. For example, when the exit animation is in progress, if the show state is restored to true, SwiftUI will retain the current branch state (without recreating the view, see the sample code attached to this article).

Custom Transitions

Implementing custom transitions in SwiftUI is not difficult, unless you need to create cool visual effects. In most cases, you can combine the animatable components provided by SwiftUI.

Swift
struct MyTransition: ViewModifier { // The wrapper object for the custom transition needs to conform to the ViewModifier protocol
    let rotation: Angle
    func body(content: Content) -> some View {
        content
            .rotationEffect(rotation) // animatable component
    }
}

extension AnyTransition {
    static var rotation: AnyTransition {
        AnyTransition.modifier(
            active: MyTransition(rotation: .degrees(360)),
            identity: MyTransition(rotation: .zero)
        )
    }
}

struct CustomTransitionDemo: View {
    @State var show = true
    var body: some View {
        VStack {
            VStack {
                Spacer()
                Text("Hello")
                if show {
                    Text("World")
                        .transition(.rotation.combined(with: .opacity))
                }
                Spacer()
            }
            .animation(.easeInOut(duration: 2), value: show) // declare animation here, the text of the Button will not have animation effect
            Button(show ? "Hide" : "Show") {
                show.toggle()
            }
        }
//        .animation(.easeInOut(duration: 2), value: show) // if declared here, the text of the Button will also be affected, resulting in the following image
        .frame(width: 300, height: 300)
        .onChange(of: show) {
            print($0)
        }
    }
}

https://cdn.fatbobman.com/custom_transition_2022-05-04_19.55.51.2022-05-04%2019_56_55.gif

Although MyTransition does not appear to conform to the Animatable protocol, the rotationEffect within it (which is an animatable ViewModifier) helps us achieve the animation effect.

Additionally, we can also use GeometryEffect (which conforms to both ViewModifier and Animatable) to create complex transition effects.

For more advanced customization of transitions, please refer to Javier’s article, Advanced SwiftUI Transitions.

State, View Identity, and Animation

Since SwiftUI animations create smooth transitions from one state to another, we must have a correct understanding of the potential outcomes resulting from changes to the state (dependencies).

SwiftUI uses two types of identifiers for views: structural identifiers and explicit identifiers. For animations, the considerations for each type of identifier differ slightly.

Structural Identification

The following two code snippets both use structural view identification (identifying by their hierarchical position and type within the view), but their intentions are completely different.

Swift
// Code One
if show {
    Text("Hello")  // Branch One
} else {
    Text("Hello")  // Branch Two
      .offset(y : 100)
}

// Code Two
Text("Hello")
    .offset(y : show ? 100 : 0)  // Two states of the same view are declared

Code One describes switching between Branch One and Branch Two in SwiftUI when the dependent variable show changes. In this case, we can set the entrance and exit animations of Branch One and Branch Two separately using transition (or set Transition uniformly outside of the branch selection), but we cannot require Branch One to move above Branch Two.

Swift
// Code One
VStack{  // Use Layout Container
    if !show {
        Text("Hello")  // Branch One
           .transition(.scale)
    } else {
        Text("Hello")  // Branch Two
          .offset(y : 100)
          .transition(.move(edge: .bottom))
    }
}
.animation(.easeIn, value: show)

https://cdn.fatbobman.com/status_for_transition_2022-05-09_15.11.26.2022-05-09%2015_12_10.gif

There are two important points to note in the above code:

  • animation must be used outside the conditional statement, because only when it is declared outside the if-else statement, the scope will be valid for the show judgment.
  • The conditional statement should be wrapped in a layout container (VStack, ZStack, HStack view) (do not use Group). Since both branch views will appear simultaneously during the transition, the transition animation can only be correctly handled in the layout container. Group can only set its child elements uniformly and cannot handle the situation where both branch views appear at the same time (one view branch transition will be lost).

Code 2 describes the different states of the same view when show changes (offset has different y values). Therefore, after being associated with the timing curve function, the view will move from the position of state 1 (y: 0) to the position of state 2 (y: 100).

Swift
// Code 2
Text("Hello")
    .offset(y: show ? 100 : 0) // Declare two states of the same view
    .animation(.spring(), value: show)

https://cdn.fatbobman.com/status_offset_2022-05-09_15.14.12.2022-05-09%2015_14_45.gif

For information on the structural identification of views, see ViewBuilder Research: Creating a ViewBuilder imitation.

Explicit Identification

In SwiftUI, there are two ways to set explicit identification for views: ForEach and the id modifier.

  • Provide a stable and unique KeyPath as the identifier for ForEach.
Swift
struct Demo: View {
    @State var items = (0...100).map { $0 }
    var body: some View {
        VStack {
            List {
                ForEach(items, id: \.self) { item in // id: \\.self uses element as the identifier
                    Text("\(item)")
                }
            }
            .animation(.easeInOut, value: items)
            Button("Remove Second") {
                items.remove(at: 1)
            }
            Button("add Second") {  // the same element can appear multiple times in items, breaking the uniqueness of the identifier
                items.insert(Int.random(in: 0...100), at: 1)
            }
        }
        .frame(width: 400, height: 500)
    }
}

items is an array of integers. In the above code, \.self is used as the identifier. This means that when there are two identical elements in the array (when the add button is clicked), SwiftUI will not be able to correctly identify our intention — which element (the same value means the same identifier) we want to operate on. Therefore, there is a high probability of animation abnormalities due to incorrect identification of the view. In the animation below, SwiftUI gives a warning when duplicate elements appear.

https://cdn.fatbobman.com/foreach_id_error_2022-05-09_16.41.18.2022-05-09%2016_43_22.gif

Providing a data source with unique identifiers for ForEach can effectively avoid animation abnormalities caused by this.

Swift
struct Item: Identifiable, Equatable {
    let id = UUID() // unique identifier
    let number: Int
}

struct Demo: View {
    @State var items = (0...100).map { Item(number: $0) }
    var body: some View {
        VStack {
            List { // Currently unable to specify transition for items in List, another example of poor compatibility with SwiftUI animation in the original control. Switching to ScrollView can support transition for specified items.
                ForEach(items, id: \.id) { item in
                    Text("\(item.number)")
                }
            }
            .animation(.easeInOut, value: items) // The List uses this association to handle animations rather than ForEach.
            Button("Remove Second") {
                items.remove(at: 1)
            }
            Button("add Second") {
                items.insert(Item(number: Int.random(in: 0...100)), at: 1)
            }
        }
        .frame(width: 400, height: 500)
    }
}
  • Modifier id needs to use transition.

The id modifier is another way to provide explicit identification for a view. When the value of the id modifier changes, SwiftUI removes the view it applies to from the current view hierarchy and creates a new one that is added to the original view’s position in the hierarchy. Therefore, any animation that affects it is also an AnyTransaction.

Swift
struct Demo: View {
    @State var id = UUID()
    var body: some View {
        VStack {
            Spacer()
            Text("Hello \(UUID().uuidString)")
                .id(id) // the original view is removed and a new view is added when the id changes
                .transition(.slide)
                .animation(.easeOut, value: id)
            Button("Update id") {
                id = UUID()
            }
            Spacer()
        }
        .frame(width: 300, height: 300)
    }
}

https://cdn.fatbobman.com/id_transition_2022-05-09_16.58.42.2022-05-09%2016_59_17-2086776.gif

Currently, SwiftUI’s logic for handling view transitions caused by changes in id values is not very consistent. If you encounter a transition that cannot be activated using animation (such as opacity), you can try using withAnimation.

Regrets and Prospects

In theory, once you have mastered the animation mechanism of SwiftUI, you should be able to easily control the animation with code. However, reality is cruel. As SwiftUI is a young framework, many of its underlying implementations still rely on encapsulating APIs of other frameworks, resulting in disjointed user experiences in many scenarios.

Animation problem with controls

Many of the controls in SwiftUI are implemented by encapsulating UIKit (or AppKit) controls, and the current animation processing is not sufficient.

In the article ViewBuilder Research: Creating a ViewBuilder imitation, we demonstrated how SwiftUI’s Text handles its extension methods. Although UIViewRepresentableContext provides Transaction information for animation control for underlying controls, the official controls in SwiftUI do not respond to this. For example, the following code cannot achieve smooth transitions:

Swift
Text("Hello world")
    .foregroundColor(animated ? .red : .blue) // Extensions of controls encapsulated based on UIKit (AppKit) can hardly achieve animation control
    .font(animated ? .callout : .title3)

Although we can solve these problems through some methods, it not only increases the workload but also loses some performance.

Paul Hudson demonstrated how to create smooth transition animations for font size in the article How to animate the size of text.

The following code can help Text achieve smooth transition of text color.

Swift
extension View {
    func animatableForeground(_ color: Color) -> some View {
        self
            .overlay(Rectangle().fill(color))
            .mask {
                self
                    .blendMode(.overlay)
            }
    }
}

struct Demo: View {
    @State var animated = false
    var body: some View {
        VStack {
            Button("Animate") {
                animated.toggle()
            }
            Text("Hello world")
                .font(.title)
                .animatableForeground(animated ? .green : .orange)
                .animation(.easeInOut(duration: 1), value: animated)
        }
    }
}

https://cdn.fatbobman.com/animatable_color_of_text_2022-05-05_14.35.19.2022-05-05%2014_36_15.gif

SwiftUI 4.0’s Text provides support for the above scenario through the newly added content transition.

To distinguish it from SwiftUI’s original Transition concept, SwiftUI 4.0 refers to the animation transition inside this control as a content transition. Developers can set the content transition mode using .contentTransition.

Swift
// SwiftUI 4.0 (iOS 16+, macOS 13+)
struct ContentTransitionDemo: View {
    @State var change = false
    var body: some View {
        VStack{
            Button("Change"){
                change.toggle()
            }
            .buttonStyle(.bordered)
            Spacer()
            Text("Hello, World!")
                .font(change ? .body : .largeTitle)
                .foregroundStyle( change ? Color.red.gradient : Color.blue.gradient)
                .fontWeight(change ? .thin : .heavy)
                .animation(.easeInOut, value: change)
        }
        .frame(height:100)
    }
}

https://cdn.fatbobman.com/contentTransition_demo1_2022-06-10_09.07.48.2022-06-10%2009_08_58.gif

Enabling content transitions still requires following the three elements of SwiftUI animations, and an easing timing function must be set for the animation.

Swift
Text("Hello, World!")
                .font(change ? .body : .largeTitle)
                .foregroundStyle( change ? Color.red.gradient : Color.blue.gradient)
                .fontWeight(change ? .thin : .heavy)
                .animation(.easeInOut, value: change)
                .contentTransition(.opacity)  // Set the content transition mode, the default is interpolate

The currently supported contentTransition modes are:

  • interpolate (default)

The demo effect is shown in the above figure. Automatic drawing of interpolation animations is implemented. The logic and effect of implementation are basically equivalent to the custom animation Text mentioned earlier.

  • opacity

https://cdn.fatbobman.com/contentTransition_demo2_2022-06-10_09.18.06.2022-06-10%2009_19_47.gif

  • identity

https://cdn.fatbobman.com/contentTransition_demo3_2022-06-10_09.25.27.2022-06-10%2009_26_04.gif

Content transition mode can also be set through environmental values:

Swift
            Text("Hello, World!")
                .font(change ? .body : .largeTitle)
                .foregroundStyle(change ? Color.red.gradient : Color.blue.gradient)
                .fontWeight(change ? .thin : .heavy)
                .animation(.easeInOut, value: change)
                .environment(\.contentTransition, .opacity)  // Set using environmental value
                .environment(\.contentTransitionAddsDrawingGroup, true) // Enable GPU acceleration

If you want your custom component (a wrapper for UIKit or AppKit components) to also support content transition, you need to check the environmental value settings in the definition, for example:

Swift
struct CustomComponent: UIViewRepresentable {
    @Environment(\.contentTransition) var contentTransition
    @Environment(\.contentTransitionAddsDrawingGroup) var drawingGroup // Whether to enable GPU accelerated rendering mode

    func makeUIView(context: Context) -> some UIView {
        switch contentTransition {
        case .opacity:
            break
        case .identity:
            break
        case .interpolate:
            break
        default:
            break
        }

        if drawingGroup {

        }
        return UIView()
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {}
}

All components that use Text explicitly or implicitly can benefit from content transition, for example:

Swift
Button("Click Me") {}
          .font(change ? .body : .largeTitle)
          .foregroundStyle(change ? Color.red.gradient : Color.blue.gradient)
          .fontWeight(change ? .thin : .heavy)
          .animation(.easeInOut, value: change)

Animation issues with controllers

Compared to widget animations, animation issues with controllers are even harder to solve. NavigationView, TabView, Sheet, and other components have no native animation control solutions, and even when calling UIKit (AppKit) code, only minor adjustments can be made to the animations (such as controlling animation start). Both the means and effects are far behind SwiftUI’s native animation capabilities.

It is urgent to hope that SwiftUI can make breakthroughs in this area. In addition to making animation logic more SwiftUI-like, it would be best to use AnyTransition for controller transition settings.

Animation performance issues

It is almost inevitable that reactive animations respond slightly less than imperative animations. SwiftUI has made some efforts to optimize animation performance (such as Canvas, drawingGroup). It is hoped that with continuous code optimization and hardware improvements, the perception of this gap will become smaller and smaller.

Summary

  • Animation is a smooth transition from one state to another.
  • Three elements are required to declare an animation.
  • Master the results of changes in state - whether it’s different states of the same view or different view branches.
  • The more precise the relationship between the timing function and its dependencies, the less likely it is to produce abnormal animations.
  • Unique and stable view identifiers (whether structural or explicit) help to avoid animation abnormalities.

The animation mechanism designed by SwiftUI is still quite excellent, and I believe that as the completeness continues to improve, developers can achieve better interaction effects with less code.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways