Search code examples
iosswiftanimationswiftui

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 ?


Solution

  • 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