iosswiftxcodeswiftuiswiftui-animation

How to create complex series of animation in SwiftUI


How can "complex" animations be composed in SwiftUI? Where complex means that the complete animation is composed out of multiple sub-animation.

Example

I am working on migrating an existing project from UIKit to SwiftUI. The project uses a custom UIView to display a progress bar which animates changes between positive and negative values. Positive values are shown with a green bar, negative values with a red bar.

Assume a maxValue of 100:

So, the total animation length is always 1 second, no matter how the value changes. When changing from a positive to a negative value (or vice versa) the bar shrinks to 0 before it changes its color and becomes longer again.

Simple ProgressBar

Creating a simple progress bar in SwiftUI was no big deal:

struct SomeProgressBar: View {
    var height: CGFloat = 20
    
    var minValue: Double = 0
    var maxValue: Double = 100
    var currentValue: Double = 20
    
    var body: some View {
        let maxV = max(minValue, maxValue)
        let minV = min(minValue, maxValue)
        
        let currentV = max(min(abs(currentValue - minValue), maxV), minV)
        let isNegative = currentValue < minValue
        
        let progress = (currentV - minV) / (maxV - minV)
        
        Rectangle()
            .fill(.gray)
            .frame(height: height)
            .overlay(alignment: .leading) {
                GeometryReader { geometry in
                    Rectangle()
                        .fill(isNegative ? .red : .green)
                        .frame(width: geometry.size.width * progress)
                }
            }
            .animation(.easeInOut(duration: 0.5), value: progress)
    }
}

struct SomeProgressBarTestView: View {
    @State var value: Double = 40
    
    var body: some View {
        VStack {
            SomeProgressBar(currentValue: value)
            
            Button("Change") {
                value = .random(in: -100...100)
            }
        }
    }
}

Problem

While this works fine when using only positive or only negative values, switching between positive and negative vales is not animated as intended. For example switching between +x and -x will only change the bar color but not animate the bar with. This is no surprise since the width does not change, it is x% before and after the value change. However, the desired result would be animating the width from x to 0 and back to x.

All sources I found so far only distinguish been implicit and explicit animations. While this changes the way how the animation is implemented / described it does not seem to influence on what the animation can do.

What would be the "correct" approach to create the desired "complex" animation?

Two different animations?

Of course one could calculate if a switch between positive and negative occurred and trigger two different animations in this case with .withAnimation { ... }. Calculating the length of the first animation and the second animation from the value difference would be no problem.

However, this would only work for linear animations. When using any easing function like spring, etc. it would not be possible to achieve a continues animation with this approach.

Additionally the progress bar is only one example. What if even more animation need to be chained to get a desired result.

Is there a solution to create custom animations like these?


Solution

  • You can conform to Animatable. Extract the part relevant to the animation to a ViewModifier that conforms to Animatable.

    var body: some View {
        let maxV = max(minValue, maxValue)
        let minV = min(minValue, maxValue)
        
        let currentV = max(min(abs(currentValue - minValue), maxV), minV)
        let progress = (currentV - minV) / (maxV - minV)
        
        Rectangle()
            .fill(.gray)
            .frame(height: height)
            .overlay {
                Rectangle()
                    .modifier(ProgressBarModifier(
                        progress: currentValue < minV ? -progress : progress
                    ))
            }
            .animation(.linear(duration: 1), value: progress)
    }
    
    struct ProgressBarModifier: ViewModifier, Animatable {
        var progress: Double // between -1 and 1
        
        nonisolated var animatableData: Double {
            get { progress }
            set { progress = newValue }
        }
        
        func body(content: Content) -> some View {
            content
                .foregroundStyle(progress < 0 ? .red : .green)
                // this is clearer than GeometryReader, but GeometryReader should also work
                .scaleEffect(x: abs(progress), anchor: .leading)
        }
    }
    

    In every frame of the animation, SwiftUI will set animatableData to the some interpolated value between the start and end points of the animation. It will then call body and update the UI with the new frame.


    You can also conform the whole SomeProgressBar to Animatable, but then the animation modifier needs to be moved to the parent view.