animationswiftuiios16onappear

SwiftUI: Animate multiple views with onAppear dynamically


I want to animate multiple custom shapes and have run into the issue that I can't make subsequent shapes animate immediately after they appear for the first time.

My goal is that you can keep replacing the second shape with another shape and it immediately animates.

Basically, when the first shape appears it immediately animates as expected. The problem arises when you get the second shape in. The first one is still animating, the second one is not. You have to first remove the second shape and then toggle it back on for it to animate properly.

There is an easy fix to get the second one to animate immediately. It's by adding animated = false to the second button, but then it makes the first shape do some unexpected stuff (immediately putting it to true also doesn't work).

There is another fix for this exact example. By adding @State var animated2 and adjusting the second rectangle for animated2 would work, but I need it to work dynamically and not have to manually do it for every view - so that also goes out of the window.

When setting animated to false

You can just copy and paste it to see how it works. Any suggestions?

import SwiftUI

struct ContentView: View {
    
    @State private var showWater: Bool = false
    @State private var showWater2: Bool = false
    @State private var animated = false
    
    var body: some View {
        ZStack {
            if showWater {
                HStack {
                    // First Recangle with Shape
                    ZStack {
                        Rectangle().frame(width: 200, height: 220)
                        Wave(offset: Angle(degrees: animated ? 360 : 0), percent: Double(50)/100)
                            .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                            .frame(width: 200, height: 220)
                            .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
                            .onAppear {
                                animated = true
                            }
                            .onDisappear {
                                animated = false
                            }
                        Wave(offset: Angle(degrees: animated ? -180 : 180), percent: Double(50)/100)
                            .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                            .opacity(0.5)
                            .frame(width: 200, height: 220)
                            .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
                            .onAppear {
                                animated = true
                            }
                            .onDisappear {
                                animated = false
                            }
                    }
                    if showWater2 {
                        // Second Recangle with Shape
                        ZStack {
                            Rectangle().frame(width: 200, height: 220)
                            Wave(offset: Angle(degrees: animated ? 360 : 0), percent: Double(50)/100)
                                .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                                .frame(width: 200, height: 220)
                                .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
                                .onAppear {
                                    animated = true
                                }
                                .onDisappear {
                                    animated = false
                                }
                            Wave(offset: Angle(degrees: animated ? -180 : 180), percent: Double(50)/100)
                                .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                                .opacity(0.5)
                                .frame(width: 200, height: 220)
                                .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
                        }
                        .onAppear {
                            animated = true
                        }
                        .onDisappear {
                            animated = false
                        }
                    }
                }
            }
            HStack {
                Button("Water") {
                    showWater.toggle()
                }
                if showWater {
                    Button("Water2") {
//                        animated = false
                        showWater2.toggle()
                        
                    }
                }
            }
            .offset(y: -150)
        }
    }
}

struct Wave: Shape {
    
    var offset: Angle
    var percent: Double
    
    var animatableData: Double {
        get { offset.degrees }
        set { offset = Angle(degrees: newValue) }
    }
    
    func path(in rect: CGRect) -> Path {
        
        var p = Path()
        let waveHeight = 0.015 * rect.height
        let yOffset = CGFloat(1 - percent) * (rect.height - 4 * waveHeight) + 2 * waveHeight
        let startAngle = offset
        let endAngle = offset + Angle(degrees: 360)
        p.move(to: CGPoint(x: 0, y: yOffset + waveHeight * CGFloat(sin(offset.radians))))
        
        for angle in stride(from: startAngle.degrees, through: endAngle.degrees, by: 5) {
            let x = CGFloat((angle - startAngle.degrees) / 360) * rect.width
            p.addLine(to: CGPoint(x: x, y: yOffset + waveHeight * CGFloat(sin(Angle(degrees: angle).radians))))
        }
        
        p.addLine(to: CGPoint(x: rect.width, y: rect.height))
        p.addLine(to: CGPoint(x: 0, y: rect.height))
        p.closeSubpath()
        
        return p
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            ContentView()
        }
        .environment(\.colorScheme, .dark)
    }
}

Solution

  • Swift animates by observing a before state and an after state. Your first wave animation works because animated = false is the before and animated = true is the after. For the second wave, animated is always true, so there is no change of state to animate.

    As you noted, giving the second view its own animated var fixes the animation.

    The best way to do this is to make a new AnimatedWave View and move the animated var inside this view. Then, adding a second wave is as simple as calling AnimatedWave() and it will get its own copy of the animated state var and it will animate correctly:

    struct ContentView: View {
        
        @State private var showWater = false
        @State private var showWater2 = false
        
        var body: some View {
            ZStack {
                if showWater {
                    HStack {
                        // First Recangle with Shape
                        AnimatedWave()
                        if showWater2 {
                            AnimatedWave()
                        }
                    }
                }
                HStack {
                    Button("Water") {
                        showWater.toggle()
                    }
                    if showWater {
                        Button("Water2") {
                            showWater2.toggle()
                        }
                    }
                }
                .offset(y: -150)
            }
        }
    }
    
    struct AnimatedWave: View {
        @State private var animated = false
        
        var body: some View {
            ZStack {
                Rectangle().frame(width: 200, height: 220)
                Wave(offset: Angle(degrees: animated ? 360 : 0), percent: Double(50)/100)
                    .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                    .frame(width: 200, height: 220)
                    .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
                    .onAppear {
                        animated = true
                    }
                    .onDisappear {
                        animated = false
                    }
                Wave(offset: Angle(degrees: animated ? -180 : 180), percent: Double(50)/100)
                    .fill(Color(red: 0, green: 0.5, blue: 0.75, opacity: 0.5))
                    .opacity(0.5)
                    .frame(width: 200, height: 220)
                    .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animated)
            }
            .onAppear {
                animated = true
            }
            .onDisappear {
                animated = false
            }
        }
    }