Search code examples
swiftswiftuiswiftui-animation

Why animation is speeding up after changing a property? SwiftUI


I tried to do this Particle Animation and wanted to change the color property. Every time I do it, the animation speeds up. How can I prevent this from happening? I'm kinda new to this, so is there a better approach?

This is my Emitter


struct EmitterView: View {
    
    private struct ParticleView: View {
        
        let image: Image
        
        @State private var isActive = false
        let position: ParticleState<CGPoint>
        let opacity: ParticleState<Double>
        let rotation: ParticleState<Angle>
        let scale: ParticleState<CGFloat>
        
        var body: some View {
            image
                .opacity(isActive ? opacity.end : opacity.start)
                .scaleEffect(isActive ? scale.end : scale.start)
                .rotationEffect(isActive ? rotation.end : rotation.start)
                .position(isActive ? position.end : position.start)
                .onAppear{self.isActive = true}
        }
    }
    
    private struct ParticleState<T> {
        var start: T
        var end: T
        
        init(_ start: T, _ end: T) {
            self.start = start
            self.end = end
        }
    }
    
    var images: [String]
    
    var particleCount: Int
    
    var creationPoint = UnitPoint.center
    var creationRange = CGSize.zero
    
    var colors = [Color.white]
    var blendMode = BlendMode.normal
    
    var angle = Angle.zero
    var angleRange = Angle.zero
    
    var opacity = 1.0
    var opacityRange = 0.0
    var opacitySpeed = 0.0
    
    var rotation = Angle.zero
    var rotationRange = Angle.zero
    var rotationSpeed = Angle.zero
    
    var scale: CGFloat = 1
    var scaleRange: CGFloat = 0
    var scaleSpeed: CGFloat = 0
    
    var speed = 0.0
    var speedRange = 0.0
    
    var animation = Animation.linear.repeatForever(autoreverses: false)
    var animationDelayTreshold = 0.0
        
    var body: some View {
        
        GeometryReader { geo in
            ZStack {
                ForEach(0..<self.particleCount, id: \.self) { i in
                    
                    ParticleView(
                        image: Image(images.randomElement()!),
                        position: self.position(in: geo),
                        opacity: self.makeOpacity(),
                        rotation: self.makeRotation(),
                        scale: self.makeScale()
                    )
                    
                    .animation(self.animation.delay(Double.random(in: 0...self.animationDelayTreshold)))
                    .colorMultiply(self.colors.randomElement() ?? .white)
                    .blendMode(self.blendMode)
                }
            }
        }
    }
    
    private func position(in proxy: GeometryProxy) -> ParticleState<CGPoint> {
        let halfCreationRangeWidth = creationRange.width / 2
        let halfCreationRangeHeight = creationRange.height / 2
        
        let creationOffsetX = CGFloat.random(in: -halfCreationRangeWidth...halfCreationRangeWidth)
        let creationOffsetY = CGFloat.random(in: -halfCreationRangeHeight...halfCreationRangeHeight)
        
        let startX = (proxy.size.width * (creationPoint.x + creationOffsetX))
        let startY = (proxy.size.height * (creationPoint.y + creationOffsetY))
        let start = CGPoint(x: startX, y: startY)
        
        let halfSpeedRange = speedRange / 2
        let actualSpeed  = Double.random(in: speed - halfSpeedRange...speed + halfSpeedRange)
        
        let halfAngleRange = angleRange.radians / 2
        let totalRange = Double.random(in: angle.radians - halfAngleRange...angle.radians + halfAngleRange)
        
        let finalX = cos(totalRange - .pi / 2) * actualSpeed
        let finalY = sin(totalRange - .pi / 2) * actualSpeed
        let end = CGPoint(x: Double(startX) + finalX, y: Double(startY) + finalY)
        
        return ParticleState(start, end)
    }
    
    private func makeOpacity() -> ParticleState<Double> {
        let halfOpacityRange = opacity / 2
        let randomOpacity = Double.random(in: -halfOpacityRange...halfOpacityRange)
        
        return ParticleState(opacity + randomOpacity, opacity + opacitySpeed + randomOpacity)
    }
    
    private func makeScale() -> ParticleState<CGFloat> {
        let halfScaleRange = scaleRange / 2
        let randomScale = CGFloat.random(in: -halfScaleRange...halfScaleRange)
        
        return ParticleState(scale + randomScale, scale + scaleSpeed + randomScale)
    }
    
    private func makeRotation() -> ParticleState<Angle> {
        let halfRotationRange = (rotationRange / 2).radians
        let randomRotation = Double.random(in: -halfRotationRange...halfRotationRange)
        let randomRotationAngle = Angle(radians: randomRotation)
        
        return ParticleState(rotation + randomRotationAngle, rotation + rotationSpeed + randomRotationAngle)
    }
    
    mutating func makeRed() {
        colors = [.red]
    }
    
}

And this is how I implemented it

import SwiftUI

struct ContentView: View {
    
    @State var emitter =  EmitterView(images: ["spark"], particleCount: 200, creationRange: CGSize(width: 0.4, height: 0.2), colors: [.white], blendMode: .screen, angle: .degrees(0), angleRange: .degrees(360), opacityRange: 0, opacitySpeed: 15, scale: 0.5, scaleRange: 0.2, scaleSpeed: -0.2, speed: 50, speedRange: 120, animation: Animation.linear(duration: 1).repeatForever(autoreverses: false), animationDelayTreshold: 1)
    
    
    var body: some View {
        
            ZStack {
                emitter
                    .ignoresSafeArea()
                }
            .background(.black)
            .edgesIgnoringSafeArea(.all)
            .statusBar(hidden: true)
            .onTapGesture {
                    emitter.makeRed()
            }
        }
    }

I also tried with transaction, but I couldn't make it work, the animation won't restart.


Solution

  • View should be in body, animation should be joined to corresponding trigger state.

    Find below fixed parts. Tested with Xcode 13.4 / iOS 15.5

    demo

        @State var colors = [Color.white]  // data !!
        var body: some View {
    
            ZStack {
                // view is here !!
                EmitterView(images: ["spark"], particleCount: 200, creationRange: CGSize(width: 0.4, height: 0.2), colors: colors, blendMode: .screen, angle: .degrees(0), angleRange: .degrees(360), opacityRange: 0, opacitySpeed: 15, scale: 0.5, scaleRange: 0.2, scaleSpeed: -0.2, speed: 50, speedRange: 120, animation: Animation.linear(duration: 1).repeatForever(autoreverses: false), animationDelayTreshold: 1)
                    .ignoresSafeArea()
            }
            .background(.black)
            .edgesIgnoringSafeArea(.all)
            .statusBar(hidden: true)
            .onTapGesture {
                colors = [.red]    // update !!
            }
        }
    

    and animation where is trigger

        private struct ParticleView: View {
    
            let image: Image
    
            @State private var isActive = false
            let position: ParticleState<CGPoint>
            let opacity: ParticleState<Double>
            let rotation: ParticleState<Angle>
            let scale: ParticleState<CGFloat>
    
            var animation: Animation
            var delayTreshold = 0.0
    
    
            var body: some View {
                image
                    .opacity(isActive ? opacity.end : opacity.start)
                    .scaleEffect(isActive ? scale.end : scale.start)
                    .rotationEffect(isActive ? rotation.end : rotation.start)
                    .position(isActive ? position.end : position.start)
                    // here is animation, depends on isActive !!
                    .animation(self.animation.delay(Double.random(in: 0...self.delayTreshold)), value: isActive)
                    .onAppear{self.isActive = true}
            }
    
        }
    

    Complete test module is here