You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用SwiftUI实现视频中的流畅液态充电动画效果?

问题描述

我想实现一种液态充电动画效果:圆点在外圈生成并向内移动,当接近显示充电信息的内圈时,内圈与圆点的接触点会逐渐向上凸起,直到与移动的圆点融合,随后平复回内圈圆周。我已经写了SwiftUI代码,但动画不够流畅,内圈的凸起和回落过于生硬,请问该如何优化实现自然的液态效果?

以下是我的代码:

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
            )
        }
    }
}
优化方案

针对代码中动画生硬、不同步的问题,从以下几个维度调整可实现自然的液态弹性效果:

1. 让凸起与圆点进度绑定,替代固定时间触发

原代码用延迟触发凸起,容易导致圆点和凸起节奏脱节。改为通过圆点的progress值动态控制凸起程度,确保两者动画完全同步:

  • 在MovingDot中新增bulgeFactor属性,根据当前进度计算凸起的强度
  • 移除全局的bulgeHeight和targetAngle,改为跟踪每个圆点的状态

2. 优化凸起的曲线过渡逻辑

原代码的凸起曲线偏僵硬,调整为更贴合液体张力的动态曲线,同时让凸起宽度随高度变化,模拟真实液态的拉伸感:

struct BulgeEffect: Shape {
    var targetAngle: Double
    var bulgeHeight: CGFloat
    
    // 动态计算凸起宽度,高度越高宽度略宽
    var dynamicWidth: Double {
        0.4 + Double(bulgeHeight) * 0.02
    }
    
    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.005).forEach { angle in
            let normalizedAngle = (angle - targetAngle + .pi * 2).truncatingRemainder(dividingBy: 2 * .pi)
            let distanceFromCenter = min(normalizedAngle, 2 * .pi - normalizedAngle)
            
            let bulgeEffect: CGFloat
            if distanceFromCenter < dynamicWidth {
                // 三次曲线+正弦曲线组合,模拟自然的凸起过渡
                let t = distanceFromCenter / dynamicWidth
                bulgeEffect = bulgeHeight * (1 - pow(t, 3)) * cos(t * .pi / 2)
            } else {
                bulgeEffect = 0
            }
            
            let x = rect.midX + (radius + bulgeEffect) * cos(angle)
            let y = rect.midY + (radius + bulgeEffect) * sin(angle)
            
            angle == 0 ? path.move(to: CGPoint(x: x, y: y)) : path.addLine(to: CGPoint(x: x, y: y))
        }
        
        path.closeSubpath()
        return path
    }
}

3. 统一动画曲线,同步圆点与凸起的节奏

原代码混用多种动画曲线,导致节奏混乱。改为统一使用响应式弹簧动画,让圆点的移动、缩放和凸起高度共享同一进度:

struct MovingDot: Identifiable {
    let id = UUID()
    var startAngle: Double
    var progress: CGFloat // 0=外圈,1=内圈融合点
    var scale: CGFloat = 1.0
    
    // 根据进度动态生成凸起因子:从0→1→0的正弦曲线
    var bulgeFactor: CGFloat {
        let riseThreshold: CGFloat = 0.7
        let fadeThreshold: CGFloat = 0.95
        guard progress >= riseThreshold else { return 0 }
        guard progress < fadeThreshold else { return 0 }
        let t = (progress - riseThreshold) / (fadeThreshold - riseThreshold)
        return sin(t * .pi)
    }
}

// 在LiquidAnimation的body中动态渲染凸起
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))
            )
    }
    
    // 只渲染当前有凸起需求的圆点对应的效果
    ForEach(movingDots.filter { $0.bulgeFactor > 0 }) { dot in
        BulgeEffect(
            targetAngle: dot.startAngle,
            bulgeHeight: dotSize * 6 * dot.bulgeFactor
        )
        .fill()
        .frame(width: innerDiameter, height: innerDiameter)
        .animation(.spring(response: 0.4, dampingFraction: 0.8, blendDuration: 0.2), value: dot.bulgeFactor)
    }
}

4. 优化圆点的生命周期逻辑

原代码用Timer和固定延迟容易出现时间偏差,改为用动画的completion回调控制圆点的生成、移动、融合和移除:

private func spawnDot() {
    let startAngle = Double.random(in: 0...(2 * .pi))
    let newDot = MovingDot(startAngle: startAngle, progress: 0)
    movingDots.append(newDot)
    
    // 圆点移动到接近内圈的位置
    withAnimation(.spring(response: 1.8, dampingFraction: 0.7)) {
        newDot.progress = 0.8
        newDot.scale = 1.1
    } completion: {
        // 开始融合动画
        withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) {
            newDot.progress = 1.0
            newDot.scale = 0.01
        } completion: {
            // 移除圆点后继续生成下一个
            withAnimation(.easeOut(duration: 0.2)) {
                movingDots.removeAll { $0.id == newDot.id }
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 1.5...3)) {
                spawnDot()
            }
        }
    }
}

// 替换原startSpawningDots方法
private func startSpawningDots() {
    spawnDot()
}

5. 性能优化

  • 用更细腻的步长生成凸起路径,同时可以考虑用Path.addArc结合贝塞尔曲线替代逐点绘制,减少计算量
  • 提前缓存半径等固定值,避免在path(in:)中重复计算

调整后,动画会呈现出更自然的液态弹性效果,凸起与圆点的融合过程同步流畅,不会出现生硬的卡顿或脱节。


内容的提问来源于stack exchange,提问作者batman

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 15:15:56