batman
batman

Reputation: 2478

SwiftUI liquid animation

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 ?

Upvotes: 2

Views: 103

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 20922

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:

Diagram

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
            }
        }
    }
}

Animation


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.

Animation

Upvotes: 2

Related Questions