swiftui

How can I animate my capsule's progress when a button is clicked?


I'm currently building a MacOS App with swiftUI and I have this capsule shape, that has a water animation inside. The water animation are just waves that move horizontally created with animatable data and path. My problem is, I have two other buttons on the screen, a play button and a stop button. They are supposed to start filling the capsule with the water and stop doing it respectively, which they do, but they are supposed to do it with an animation, and it's not.

Below is my code.

GeometryReader { geometry in
                                
    VStack {

        Spacer()
        
        BreathingWave(progress: $progressValue, phase: phase)
            .fill(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
            .clipShape(Capsule())
            .border(Color.gray, width: 3)
            .frame(width: geometry.size.width / 12)
            .onAppear {
                withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
                    self.phase = .pi * 2
                }
            }
        
        HStack {

            Spacer()

            Image(systemName: "play.circle.fill")
                .resizable()
                .renderingMode(.template)
                .frame(width: 50, height: 50)
                .foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
                .scaleEffect(scalePlay ? 1.25 : 1)
                .onHover(perform: { scalPlay in
                    withAnimation(.linear(duration: 0.1)) {
                        scalePlay = scalPlay
                    }
                })
                .onTapGesture {
                    withAnimation(
                        .easeInOut(duration: duration)
                        .repeatForever(autoreverses: true)) {
                            if(progressValue < 1) {
                                progressValue += 0.1
                            }
                            else {
                                progressValue = progressValue
                            }
                        }
                }

            Spacer()

            Image(systemName: "stop.circle.fill")
                .resizable()
                .renderingMode(.template)
                .frame(width: 50, height: 50)
                .foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
                .scaleEffect(scaleStop ? 1.25 : 1)
                .onHover(perform: { scalStop in
                    withAnimation(.linear(duration: 0.1)) {
                        scaleStop = scalStop
                    }
                })
                .onTapGesture {
                    withAnimation(.linear) {
                        progressValue = 0.0
                    }
                }

            Spacer()

        }
        .padding(.bottom, 50)
    }
}

And this is the code of the BreathingWave

struct BreathingWave: Shape {
    
    @Binding var progress: Float
    var applitude: CGFloat = 10
    var waveLength: CGFloat = 20
    var phase: CGFloat

    var animatableData: CGFloat {
        get { phase }
        set { phase = newValue }
    }
    
    func path(in rect: CGRect) -> Path {
        
        var path = Path()
        
        let width = rect.width
        let height = rect.height
        let minWidth = width / 2
        let progressHeight = height * (1 - CGFloat(progress))
        
        path.move(to: CGPoint(x: 0, y: progressHeight))
        
        for x in stride(from: 0, to: width + 5, by: 5) {
            let relativeX = x / waveLength
            let normalizedLength = (x - minWidth) / minWidth
            let y = progressHeight + sin(phase + relativeX) * applitude * normalizedLength
            path.addLine(to: CGPoint(x: x, y: y))
        }
        path.addLine(to: CGPoint(x: width, y: progressHeight))
        path.addLine(to: CGPoint(x: width, y: height))
        path.addLine(to: CGPoint(x: 0, y: height))
        path.addLine(to: CGPoint(x: 0, y: progressHeight))
        
        return path
    }
}

Solution

  • The problem you are running into is simply that you never told BreathingWave how to animate for progress. You set a single dimension animation, when you really want it to animate in two dimensions. The fix is straightforward: use AnimatablePair to supply the variables to animatableData.

    struct BreathingWave: Shape {
        
        var applitude: CGFloat = 10
        var waveLength: CGFloat = 20
        // progress is no longer a Binding. Using @Binding never did anything
        // as you were not changing the value to be used in the parent view.
        var progress: Float
        var phase: CGFloat
    
        var animatableData: AnimatablePair<Float, CGFloat> { // <- AnimatablePair here
            get { AnimatablePair(progress, phase) } // <- the getter returns the pair
            set { progress = newValue.first // <- the setter sets each individually from
                phase = newValue.second     //    the pair.
            }
        }
        
        func path(in rect: CGRect) -> Path {
            ...
        }
    }
    

    Lastly, you now call it like this:

    BreathingWave(progress: progressValue, phase: phase)