Search code examples
iosanimationswiftui

Animate with scale and opacity in SwiftUI


How can I animate the circle image with scale from 0 to 1 while animating with starting position at 7 o'clock & scaling end position at 11 o'clock along the circumference of the circle and then animate with opacity from 1 to 0 while 11 o'clock to 3 o'clock. Then reposition the image back to 7 o'clock & replay the animation in a loop. With the following code, I'm able to scale and rotate at the same time but it does not follow along the circumference. I not sure how to offset the animation during the scale & rotate such that it stays along the circle.

struct ContentView: View {
    @State private var startAngle: Double = -45
    @State private var endAngle: Double = 120
    @State private var animationValue: Double = 0.0

    var body: some View {
        ZStack {
            let width: CGFloat = 60
            Circle()
                .stroke(.black, lineWidth: 1.0)
                .frame(width: width)
                .overlay {
                    Image(systemName: "circle.fill")
                        .offset(x: -width/2)
                        .modifier(ScaleAndRotateModifier(value: animationValue,
                                                         startAngle: startAngle,
                                                         endAngle: endAngle))
                        .onAppear {
                            withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
                                animationValue = 1.0
                            }
                        }
                }
        }
        .foregroundStyle(.black)
        .padding()
    }
}

struct ScaleAndRotateModifier: AnimatableModifier {
    var value: Double
    var startAngle: Double
    var endAngle: Double
    
    var animatableData: Double {
        get { value }
        set { value = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: startAngle + value * endAngle), anchor: .center)
            .scaleEffect(value * 0.5)
    }
}

Solution

  • Since this animation involves multiple stages, it would be convenient to use a keyframeAnimator here.

    Write a struct with all the properties you want to animate:

    struct AnimatedProperties {
        var angle: Angle = .degrees(120)
        var scale: CGFloat = 0
        var opacity: CGFloat = 1
    }
    

    Then you can do

    let width: CGFloat = 60
    Circle()
        .stroke(.black, lineWidth: 1.0)
        .frame(width: width)
        .overlay {
            Image(systemName: "circle.fill")
                .keyframeAnimator(initialValue: AnimatedProperties()) { content, properties in
                    content
                        .scaleEffect(properties.scale)
                        .opacity(properties.opacity)
                        .offset(x: width / 2 * cos(properties.angle.radians), y: width / 2 * sin(properties.angle.radians))
                } keyframes: { properties in
                    KeyframeTrack(\.angle) {
                        // angle goes from 120 to 480 over the course of 3 seconds
                        LinearKeyframe(.degrees(480), duration: 3)
                    }
                    KeyframeTrack(\.scale) {
                        // scale goes to 1 during the first second
                        LinearKeyframe(1, duration: 1)
                        // then stays there for 1 second
                        LinearKeyframe(1, duration: 1)
                        // then goes back to 0 in the last second
                        LinearKeyframe(0, duration: 1)
                    }
                    KeyframeTrack(\.opacity) {
                        // opacity stays at 1 for the first second
                        LinearKeyframe(1, duration: 1)
                        // then goes to 0 in the next second
                        LinearKeyframe(0, duration: 1)
                        // then goes back to 1 again
                        LinearKeyframe(1, duration: 1)
                    }
                }
    
        }
    

    Notice the line:

    .offset(x: width / 2 * cos(properties.angle.radians), y: width / 2 * sin(properties.angle.radians))
    

    This is how you compute the required offset for a given angle.

    In my opinion, this animation looks a bit funny. At around 2 seconds, the circle "flashes", because its opacity goes from non-zero to zero, then back to non-zero again, very quickly. To me this doesn't feel coherent. I think this set of key frames look much better, where the animation is only two seconds long, and the circle instantaneously moves from 3 o'clock to 7 o'clock to start a new cycle.

    } keyframes: { properties in
        KeyframeTrack(\.angle) {
            // angle goes from 120 to 360 over the course of 2 seconds
            LinearKeyframe(.degrees(360), duration: 2)
        }
        KeyframeTrack(\.scale) {
            // scale goes to 1 during the first second
            LinearKeyframe(1, duration: 1)
            // then stays there for another second
            LinearKeyframe(1, duration: 1)
        }
        KeyframeTrack(\.opacity) {
            // opacity stays at 1 for the first second
            LinearKeyframe(1, duration: 1)
            // then goes to 0 in the next second
            LinearKeyframe(0, duration: 1)
        }
    }
    

    If keyframeAnimator is not available for your target iOS version, you can still use your view modifier approach, but you'd need to calculate the scale and opacity manually.

    struct ContentView: View {
        let startAngle: Angle = .degrees(120)
        let endAngle: Angle = .degrees(480)
        @State private var animationValue: Double = 0.0
    
        var body: some View {
            ZStack {
                let width: CGFloat = 60
                Circle()
                    .stroke(.black, lineWidth: 1.0)
                    .frame(width: width)
                    .overlay {
                        Image(systemName: "circle.fill")
                            .modifier(ScaleAndRotateModifier(value: animationValue,
                                                             startAngle: startAngle.radians,
                                                             endAngle: endAngle.radians,
                                                             radius: width / 2))
                            .onAppear {
                                withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: false)) {
                                    animationValue = 1.0
                                }
                            }
                    }
            }
            .foregroundStyle(.black)
            .padding()
        }
    }
    
    struct ScaleAndRotateModifier: ViewModifier, Animatable, _RemoveGlobalActorIsolation {
        var value: Double
        let startAngle: Double
        let endAngle: Double
        let radius: Double
        
        var animatableData: Double {
            get { value }
            set { value = newValue }
        }
        
        var scale: CGFloat {
            if value * 3 < 1 {
                value * 3
            } else if value * 3 < 2 {
                1
            } else {
                3 - value * 3
            }
        }
        
        var opacity: CGFloat {
            if value * 3 < 1 {
                1
            } else if value * 3 < 2 {
                (2 - value * 3)
            } else {
                1 - (3 - value * 3)
            }
        }
        
        func body(content: Content) -> some View {
            let angle = startAngle + (endAngle - startAngle) * value
            content
                .scaleEffect(scale)
                .opacity(opacity)
                .offset(x: radius * cos(angle), y: radius * sin(angle))
        }
    }