iosswiftui

Shape not re-animating in SwiftUI


I have the following wave animation happening and on button press I animate it's strength such that it flatlines with animation. However, when I press the button again, the wave animation doesn't restart. How do I get it to re-start the animation again?

struct Wave: Shape {
    var strength: Double
    var frequency: Double
    var phase: Double
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(phase, strength) }
        set {
            self.phase = newValue.first
            self.strength = newValue.second
        }
    }
    
    func path(in rect: CGRect) -> Path {
        var path = Path()

        let width = Double(rect.width)
        let height = Double(rect.height)
        let midHeight = height / 2
        let wavelength = width / frequency

        let firstX = 0.0
        let firstRelativeX = firstX / wavelength
        let firstSine = sin(firstRelativeX + phase)
        let firstY = strength * firstSine + midHeight
        path.move(to: CGPoint(x: firstX, y: firstY))

        for x in stride(from: 0.0, through: width, by: 1) {
            let relativeX = x / wavelength
            let sine = sin(relativeX + phase)
            let y = strength * sine + midHeight
            path.addLine(to: CGPoint(x: x, y: y))
        }

        return path
    }
}

struct WaveView: View {
    @Binding var isAnimating: Bool
    @State private var phase = 0.0
    @State private var waveStrength = 50.0
    
    var body: some View {
        VStack {
            Wave(strength: waveStrength, frequency: 30, phase: phase)
                .stroke(.black, lineWidth: 5)
                .onChange(of: isAnimating) { _, newValue in
                    withAnimation(.easeInOut(duration: 0.5)) {
                        waveStrength = newValue ? 50.0 : 0.0
                    }
                    if newValue {
                        animateWave()
                    }
                }
                .onAppear {
                    animateWave()
                }
        }
    }
    
    private func animateWave() {
        withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
            self.phase = .pi * 2
        }
    }
}

struct WaveContainerView: View {
    @State var isAnimating = true

    var body: some View {
        VStack {

            WaveView(isAnimating: $isAnimating)
            
            Button("Animate") {
                isAnimating.toggle()
            }
        }
    }
}

Solution

  • To get your animation start again, you need to reset phase value before start animation. After WaveView appear, phase value get set to .pi * 2, then when you press button, you just set it to the same value, SwiftUI do not see any change of phase so your animation is not run, also when onChange closure of isAnimating get called, a new withAnimation block get called therefore current animation repeatForever get cancel because animation for phase and strength are stick together in AnimatablePair:

    private func animateWave() {
        phase = 0 // <- reset phase value here
        withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
            self.phase = .pi * 2
        }
    }
    

    You can comment out withAnimation block in onChange(of: isAnimating) to keep repeatForever animation, but then you will not have animation when waveStrength change:

    .onChange(of: isAnimating) { _, newValue in
        //withAnimation(.easeInOut(duration: 0.5)) {
        waveStrength = newValue ? 50.0 : 0.0
        //}
        if newValue {
            animateWave()
        }
    }
    

    Another fix is to seperate phase and waveStrength from AnimatablePair:

    struct ShapheWave: View, @preconcurrency Animatable {
        var strength: Double
        var frequency: Double
        var phase: Double
        
        var animatableData: Double {
            get { phase }
            set {
                self.phase = newValue
            }
        }
        
        var body: some View {
            Wave(strength: strength, frequency: frequency, phase: phase)
                .stroke(.black, lineWidth: 5)
        }
    }
    struct Wave: Shape {
        var strength: Double
        var frequency: Double
        var phase: Double = .pi * 2
        var animatableData: Double {
            get { strength }
            set {
                self.strength = newValue
            }
        }
        
        func path(in rect: CGRect) -> Path {
            var path = Path()
            
            let width = Double(rect.width)
            let height = Double(rect.height)
            let midHeight = height / 2
            let wavelength = width / frequency
            
            let firstX = 0.0
            let firstRelativeX = firstX / wavelength
            let firstSine = sin(firstRelativeX + phase)
            let firstY = strength * firstSine + midHeight
            path.move(to: CGPoint(x: firstX, y: firstY))
            
            for x in stride(from: 0.0, through: width, by: 1) {
                let relativeX = x / wavelength
                let sine = sin(relativeX + phase)
                let y = strength * sine + midHeight
                path.addLine(to: CGPoint(x: x, y: y))
            }
            
            return path
        }
    }
    
    struct WaveView: View {
        @Binding var isAnimating: Bool
        @State private var phase = 0.0
        @State private var waveStrength = 50.0
        
        var body: some View {
            VStack {
                ShapheWave(strength: waveStrength, frequency: 30, phase: phase)
                    .onChange(of: isAnimating) { _, newValue in
                        waveStrength = newValue ? 50.0 : 0.0
                    }
                    .onAppear {
                        animateWave()
                    }
                    .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: phase)              .animation(.easeInOut(duration: 0.5), value: waveStrength)
            }
        }
        
        private func animateWave() {
            phase = .pi * 2
        }
    }