如何用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
相关产品推荐
相关产品推荐

