admin管理员组

文章数量:1304130

I'm trying to create a sort of liquid animation as seen here (static image). A video of the effect can be seen in this youtube video from around 35s mark. Dots spawn on the outermost circle and move inwards. As they approach the innermost circle displaying charging information, the point of contact of the dot with the circle sort of animates upwards gradually until it makes contact with the moving dot and then flatlines back to the circumference of the circle. Here's my code but the animation is not quite there, the circumference sort of abruptly scales up and back down and is not fluid.

struct MovingDot: Identifiable {
    let id = UUID()
    var startAngle: Double
    var progress: CGFloat
    var scale: CGFloat = 1.0
}

struct BulgeEffect: Shape {
    var targetAngle: Double
    var bulgeHeight: CGFloat
    var bulgeWidth: Double
    
    var animatableData: AnimatablePair<Double, CGFloat> {
        get { AnimatablePair(targetAngle, bulgeHeight) }
        set {
            targetAngle = newValue.first
            bulgeHeight = newValue.second
        }
    }
    
    func path(in rect: CGRect) -> Path {
        let radius = rect.width / 2
        var path = Path()
        
        stride(from: 0, to: 2 * .pi, by: 0.01).forEach { angle in
            let normalizedAngle = (angle - targetAngle + .pi * 2).truncatingRemainder(dividingBy: 2 * .pi)
            let distanceFromCenter = min(normalizedAngle, 2 * .pi - normalizedAngle)
            
            let bulgeEffect = distanceFromCenter < bulgeWidth
                ? bulgeHeight * pow(cos(distanceFromCenter / bulgeWidth * .pi / 2), 2)
                : 0
                
            let x = rect.midX + (radius + bulgeEffect) * cos(angle)
            let y = rect.midY + (radius + bulgeEffect) * sin(angle)
            
            if angle == 0 {
                path.move(to: CGPoint(x: x, y: y))
            } else {
                path.addLine(to: CGPoint(x: x, y: y))
            }
        }
        
        path.closeSubpath()
        return path
    }
}

struct LiquidAnimation: View {
    let outerDiameter: CGFloat
    let innerDiameter: CGFloat
    let dotSize: CGFloat
    
    @State private var movingDots: [MovingDot] = []
    @State private var bulgeHeight: CGFloat = 0
    @State private var targetAngle: Double = 0
    
    var body: some View {
        ZStack {
            ForEach(movingDots) { dot in
                Circle()
                    .frame(width: dotSize * 2, height: dotSize * 2)
                    .scaleEffect(dot.scale)
                    .position(
                        x: outerDiameter/2 + cos(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2)),
                        y: outerDiameter/2 + sin(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2))
                    )
            }
            
            BulgeEffect(targetAngle: targetAngle, bulgeHeight: bulgeHeight, bulgeWidth: 0.6)
                .fill()
                .frame(width: innerDiameter, height: innerDiameter)
                .animation(.spring(response: 0.3, dampingFraction: 0.6), value: bulgeHeight)
        }
        .frame(width: outerDiameter, height: outerDiameter)
        .onAppear(perform: startSpawningDots)
    }
    
    private func startSpawningDots() {
        Timer.scheduledTimer(withTimeInterval: Double.random(in: 2...5), repeats: true) { _ in
            let startAngle = Double.random(in: 0...(2 * .pi))
            let newDot = MovingDot(startAngle: startAngle, progress: 0)
            
            movingDots.append(newDot)
            
            withAnimation(.easeIn(duration: 1.5)) {
                movingDots[movingDots.count - 1].progress = 0.8
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                targetAngle = startAngle
                withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                    bulgeHeight = dotSize * 8
                }
                
                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].scale = 1.2
                }
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].progress = 1
                    movingDots[movingDots.count - 1].scale = 0.1
                }
                
                withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
                    bulgeHeight = 0
                }
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                movingDots.removeAll { $0.id == newDot.id }
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            LiquidAnimation(
                outerDiameter: 350,
                innerDiameter: 150,
                dotSize: 4
            )
        }
    }
}

How can I achieve the same effect as in the video ?

I'm trying to create a sort of liquid animation as seen here (static image). A video of the effect can be seen in this youtube video from around 35s mark. Dots spawn on the outermost circle and move inwards. As they approach the innermost circle displaying charging information, the point of contact of the dot with the circle sort of animates upwards gradually until it makes contact with the moving dot and then flatlines back to the circumference of the circle. Here's my code but the animation is not quite there, the circumference sort of abruptly scales up and back down and is not fluid.

struct MovingDot: Identifiable {
    let id = UUID()
    var startAngle: Double
    var progress: CGFloat
    var scale: CGFloat = 1.0
}

struct BulgeEffect: Shape {
    var targetAngle: Double
    var bulgeHeight: CGFloat
    var bulgeWidth: Double
    
    var animatableData: AnimatablePair<Double, CGFloat> {
        get { AnimatablePair(targetAngle, bulgeHeight) }
        set {
            targetAngle = newValue.first
            bulgeHeight = newValue.second
        }
    }
    
    func path(in rect: CGRect) -> Path {
        let radius = rect.width / 2
        var path = Path()
        
        stride(from: 0, to: 2 * .pi, by: 0.01).forEach { angle in
            let normalizedAngle = (angle - targetAngle + .pi * 2).truncatingRemainder(dividingBy: 2 * .pi)
            let distanceFromCenter = min(normalizedAngle, 2 * .pi - normalizedAngle)
            
            let bulgeEffect = distanceFromCenter < bulgeWidth
                ? bulgeHeight * pow(cos(distanceFromCenter / bulgeWidth * .pi / 2), 2)
                : 0
                
            let x = rect.midX + (radius + bulgeEffect) * cos(angle)
            let y = rect.midY + (radius + bulgeEffect) * sin(angle)
            
            if angle == 0 {
                path.move(to: CGPoint(x: x, y: y))
            } else {
                path.addLine(to: CGPoint(x: x, y: y))
            }
        }
        
        path.closeSubpath()
        return path
    }
}

struct LiquidAnimation: View {
    let outerDiameter: CGFloat
    let innerDiameter: CGFloat
    let dotSize: CGFloat
    
    @State private var movingDots: [MovingDot] = []
    @State private var bulgeHeight: CGFloat = 0
    @State private var targetAngle: Double = 0
    
    var body: some View {
        ZStack {
            ForEach(movingDots) { dot in
                Circle()
                    .frame(width: dotSize * 2, height: dotSize * 2)
                    .scaleEffect(dot.scale)
                    .position(
                        x: outerDiameter/2 + cos(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2)),
                        y: outerDiameter/2 + sin(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2))
                    )
            }
            
            BulgeEffect(targetAngle: targetAngle, bulgeHeight: bulgeHeight, bulgeWidth: 0.6)
                .fill()
                .frame(width: innerDiameter, height: innerDiameter)
                .animation(.spring(response: 0.3, dampingFraction: 0.6), value: bulgeHeight)
        }
        .frame(width: outerDiameter, height: outerDiameter)
        .onAppear(perform: startSpawningDots)
    }
    
    private func startSpawningDots() {
        Timer.scheduledTimer(withTimeInterval: Double.random(in: 2...5), repeats: true) { _ in
            let startAngle = Double.random(in: 0...(2 * .pi))
            let newDot = MovingDot(startAngle: startAngle, progress: 0)
            
            movingDots.append(newDot)
            
            withAnimation(.easeIn(duration: 1.5)) {
                movingDots[movingDots.count - 1].progress = 0.8
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                targetAngle = startAngle
                withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                    bulgeHeight = dotSize * 8
                }
                
                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].scale = 1.2
                }
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].progress = 1
                    movingDots[movingDots.count - 1].scale = 0.1
                }
                
                withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
                    bulgeHeight = 0
                }
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                movingDots.removeAll { $0.id == newDot.id }
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            LiquidAnimation(
                outerDiameter: 350,
                innerDiameter: 150,
                dotSize: 4
            )
        }
    }
}

How can I achieve the same effect as in the video ?

Share Improve this question edited Feb 5 at 23:03 Benzy Neez 22.2k3 gold badges14 silver badges41 bronze badges asked Feb 4 at 16:37 batmanbatman 2,4422 gold badges27 silver badges51 bronze badges 5
  • When a dot comes in near to the 3 o'clock angle, the bulge has a cut in it. This may be a symptom of an angle going negative, or going past 360 degrees. It might help to perform the effect at some safe position, say at 12 o'clock, and then rotate the bulge into the required position. – Benzy Neez Commented Feb 4 at 17:04
  • Good spot @BenzyNeez, I tried fixing it but it didn't work quite as intended. I'm afk so I can't post an update to the code. – batman Commented Feb 5 at 13:10
  • @BenzyNeez I think I've managed to solve the 3 o'clock dot issue (code updated in the question). However I haven't been able to replicate the exact animation. The current animation looks like it is originating from further inside the circle rather than from the surface (circumference). I think if that is addressed, it should be closer to the end result I'm looking for. – batman Commented Feb 5 at 14:59
  • @BenzyNeez What I mean by that is the ends of the curve look much sharper at the point of contact with the circumference instead of looking like an extension of the circumference. – batman Commented Feb 5 at 15:04
  • Purely FWIW to do this sort of thing you use a soft body physics engine - usually en.wikipedia./wiki/Bullet_(software) pybullet.. enjoy! – Fattie Commented Feb 6 at 0:13
Add a comment  | 

1 Answer 1

Reset to default 4

I would describe this animation effect as the reverse of the droplet motion commonly seen in coffee advertisements. A liquid drop normally causes a "rebound" with a small circular drop escaping the surface tension. The effect in this animation seems to start with that circular drop, so it's like playing the droplet motion backwards. Not easy to implement!

You have managed to get quite far with your example, but the shape of the bulge is not quite right. I've focused on trying to make this part better.


I would suggest building the bulge shape by adding arcs to the path. The following diagram illustrates how the bulge can be based on the outline of two adjoining circles:

The bulge starts at point A, proceeding along the circumference of the circle with center point B. When it reaches the tangent with the smaller circle, it proceeds along the circumference of the smaller circle. This makes the point of the bulge. The reverse arc is then applied on the other side.

Here is an implementation of a shape that works this way:

struct Bulge: Shape {
    let bulgeAngle: Angle // alpha
    let circleRadius: CGFloat
    let bulgeBeginRadius: CGFloat
    var bulgePointRadius: CGFloat

    var animatableData: CGFloat {
        get { bulgePointRadius }
        set { bulgePointRadius = newValue }
    }

    func path(in rect: CGRect) -> Path {
        Path { path in
            let sinAlpha = CGFloat(sin(bulgeAngle.radians))
            let cosAlpha = CGFloat(cos(bulgeAngle.radians))
            let pointA = CGPoint(
                x: rect.midX - (circleRadius * sinAlpha),
                y: rect.midY - (circleRadius * cosAlpha)
            )
            let pointB = CGPoint(
                x: rect.midX - ((circleRadius + bulgeBeginRadius) * sinAlpha),
                y: rect.midY - ((circleRadius + bulgeBeginRadius) * cosAlpha)
            )
            let beta = min(
                (Double.pi / 2) - bulgeAngle.radians,
                acos(Double(rect.midX - pointB.x) / (bulgeBeginRadius + bulgePointRadius))
            )
            let pointC = CGPoint(
                x: rect.midX,
                y: pointB.y + (sin(beta) * (bulgeBeginRadius + bulgePointRadius))
            )
            let pointD = CGPoint(
                x: rect.midX + ((circleRadius + bulgeBeginRadius) * sinAlpha),
                y: pointB.y
            )
            path.move(to: pointA)
            path.addArc(
                center: pointB,
                radius: bulgeBeginRadius,
                startAngle: .radians(Double.pi / 2) - bulgeAngle,
                endAngle: .radians(beta),
                clockwise: true
            )
            path.addArc(
                center: pointC,
                radius: bulgePointRadius,
                startAngle: .radians(Double.pi + beta),
                endAngle: .radians(-beta),
                clockwise: false
            )
            path.addArc(
                center: pointD,
                radius: bulgeBeginRadius,
                startAngle: .radians(Double.pi - beta),
                endAngle: .radians(Double.pi / 2) + bulgeAngle,
                clockwise: true
            )
        }
    }
}

The bulge can be animated by changing the radius for the small circle (the bulge point), as illustrated with this demo:

struct BulgeDemo: View {
    let bulgeAngle = Angle.degrees(25) // alpha
    let circleRadius: CGFloat = 75
    let bulgeBeginRadius: CGFloat = 100
    @State private var bulgePointRadius: CGFloat = 10

    var body: some View {
        ZStack {
            Circle()
                .stroke()
                .frame(width: circleRadius * 2, height: circleRadius * 2)
            Bulge(
                bulgeAngle: bulgeAngle,
                circleRadius: circleRadius,
                bulgeBeginRadius: bulgeBeginRadius,
                bulgePointRadius: bulgePointRadius
            )
            .stroke(.blue, lineWidth: 3)
        }
        .onAppear {
            withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                bulgePointRadius = circleRadius
            }
        }
    }
}


This bulge can now be plugged into your original LiquidAnimation. The main changes needed:

  • A circle is now the first layer in the ZStack.

  • The new Bulge shape replaces BulgeEffect.

  • A .rotationEffect is used to align the bulge with the incoming dot.

  • Before I was able to work out the cap to apply to the angle beta, I found that a .spring animation caused some strange effects. This is fixed now, but using a simpler animation like .easeIn works quite well anyway.

struct LiquidAnimation: View {
    let outerDiameter: CGFloat
    let innerDiameter: CGFloat
    let dotSize: CGFloat
    let bulgeAngle = Angle.degrees(25) // alpha
    let bulgeBeginRadius: CGFloat = 100
    let minBulgePointRadius: CGFloat = 10

    @State private var movingDots: [MovingDot] = []
    @State private var targetAngle: Double = 0
    @State private var bulgePointRadius: CGFloat = 0

    var body: some View {
        ZStack {

            Circle()
                .frame(width: innerDiameter, height: innerDiameter)

            Bulge(
                bulgeAngle: bulgeAngle,
                circleRadius: innerDiameter / 2,
                bulgeBeginRadius: bulgeBeginRadius,
                bulgePointRadius: bulgePointRadius
            )
            .rotationEffect(.radians(targetAngle + (Double.pi / 2)))
            .onAppear { bulgePointRadius = innerDiameter / 2 }

            ForEach(movingDots) { dot in
                Circle()
                    .frame(width: dotSize * 2, height: dotSize * 2)
                    .scaleEffect(dot.scale)
                    .position(
                        x: outerDiameter/2 + cos(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2)),
                        y: outerDiameter/2 + sin(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2))
                    )
            }
        }
        .frame(width: outerDiameter, height: outerDiameter)
        .onAppear(perform: startSpawningDots)
    }

    private func startSpawningDots() {
        Timer.scheduledTimer(withTimeInterval: Double.random(in: 2...5), repeats: true) { _ in
            let startAngle = Double.random(in: 0...(2 * .pi))
            let newDot = MovingDot(startAngle: startAngle, progress: 0)

            movingDots.append(newDot)

            withAnimation(.easeIn(duration: 1.5)) {
                movingDots[movingDots.count - 1].progress = 0.8
            }

            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
                targetAngle = startAngle
//                withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                withAnimation(.easeIn) {
                    bulgePointRadius = minBulgePointRadius
                }

                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].scale = 1.2
                }
            }

            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                withAnimation(.easeOut(duration: 0.3)) {
                    movingDots[movingDots.count - 1].progress = 1
                    movingDots[movingDots.count - 1].scale = 0.1
                }

//                withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
                withAnimation(.easeIn) {
                    bulgePointRadius = innerDiameter / 2
                }
            }

            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                movingDots.removeAll { $0.id == newDot.id }
            }
        }
    }
}

The animation could still do with some polishing, but hopefully it gets you further.

本文标签: iosSwiftUI liquid animationStack Overflow