iosswiftswiftuiswiftui-animation

How to simultaneously perform multiple animations to one element in SwiftUI?


I have a WaveView which is just a sine wave and a rectangle. To make it act like a real wave, I need it to translate unstoppably. So I wrote a Wave view and added an animation on offset with a timer in .onAppear. (I've already tried .animation(.linear(duration: 3).repeatForever(autoreverses: false), value: offset) but that has the same issue) Then, as soon as the Wave view appears, it keeps moving like a wave. Everything works fine.

Then I want to add another animation to the variable progress so that I can animate the progress change as well.

What I expect is that the wave keeps moving and the progress (the height of the blue part that we can see) goes up with an animation. But as soon as the progress changes, the animation on progress gets performed but the wave animation stops, until the timer fires for the next time.

What should I do to keep the wave animation while animating progress?

p.s. Things gets worse with .animation(.linear(duration: 3).repeatForever(autoreverses: false), value: offset) since there's no timer, and I only change offset once, so once this animation gets interrupted, the wave just never start moving again.

struct WaveView: View {
    
    var waveHeight: CGFloat
    
    var body: some View {
        GeometryReader { global in
            Path { path in
                let width = global.size.width
                
                path.move(to: CGPoint(x: width*2.0, y: waveHeight))
                path.addLine(to: CGPoint(x:width*2.0,y: global.size.height))
                path.addLine(to: CGPoint(x:0,y:global.size.height))
                path.addLine(to: CGPoint(x:0,y: waveHeight))
                
                var points = [CGPoint]()
                for angle in stride(from: 0, through: 180.0*4, by: 1) {
                    let radian = angle * .pi / 180
                    let cosValue = cos(radian)
                    let x =  CGFloat(angle) * global.size.width / 360
                    let y = (1+cosValue) * waveHeight / 2
                    points.append(CGPoint(x: x, y: y))
                }
                
                path.addLines(points)
            }
            .fill(.blue.opacity(0.5))
        }
    }
}

struct Wave: View {
    
    init(progress: Binding<Float>, waveHeight height: CGFloat = 200) {
        waveHeight = height
        self._progress = progress
    }
    
    var waveHeight: CGFloat
    
    @Binding var progress: Float
    @State var offset: CGFloat = 0
    
    
    @State var timer: Timer?
    
    var body: some View {
        GeometryReader { global in
            ZStack(alignment: .bottom) {
                    WaveView(waveHeight: waveHeight)
                    .onAppear {
                        timer = .scheduledTimer(withTimeInterval: 3, repeats: true, block: { _ in
                            if offset >= 1 {
                                offset = 0
                            }
                            withAnimation(.linear(duration: 3)) {
                                offset += 1
                            }
                        })
                        timer?.fire()
                    }
                    .onDisappear {
                        timer?.invalidate()
                        timer = nil
                    }
            }
            .frame(width: global.size.width, height: global.size.height)
            
            .position(x: global.size.width * (0.5-offset),y: global.size.height/2)
            .offset(y:CGFloat(1-progress)*global.size.height)
            .animation(.easeInOut(duration: 0.4), value: progress)
            
        }
    }
}

struct TestView: View {
    
    @State var progress: Float = 0.6
    
    var body: some View {
        ZStack{
            Wave(progress: $progress, waveHeight: 35)
                .edgesIgnoringSafeArea(.all)
            VStack {
                Stepper("progress: \(progress.description)",value: $progress, in: 0.0...1.0, step: 0.2)
                    .padding(.horizontal,40)
            }
        }
    }
}

#Preview {
    TestView()
}

normal wave normal wave

wave animation that gets interrupted by animation on progress wave animation that gets interrupted by animation on progress


Solution

  • iOS 17 and above

    The way to insulate the first animation from the second is to add .geometryGroup().

    Other suggested changes:

    Here is the updated example:

    struct Wave: View {
        let waveHeight: CGFloat
        @Binding var progress: Float
        @State var offset: CGFloat = 0
    
        init(progress: Binding<Float>, waveHeight height: CGFloat = 200) {
            waveHeight = height
            self._progress = progress
        }
    
        var body: some View {
            GeometryReader { global in
                ZStack(alignment: .bottom) {
                    WaveView(waveHeight: waveHeight)
                        .onAppear { offset = 1 }
                }
                .position(x: global.size.width * (0.5-offset), y: global.size.height/2)
                .animation(.linear(duration: 3).repeatForever(autoreverses: false), value: offset)
                .geometryGroup() // 👈 IMPORTANT
    
                .offset(y: CGFloat(1-progress) * global.size.height)
                .animation(.easeInOut(duration: 0.4), value: progress)
            }
        }
    }
    

    Animation

    Earlier iOS versions

    For earlier versions, .drawingGroup() also works. However, this modifier is more likely to have side effects on other aspects of presentation, so you probably want to use with caution. It does seem to fix the problem in this particular case though.