在 SwiftUI 中使用 Canvas 和 TimelineView 实现基于物理的彩纸动画
简介
彩纸动画是那种能将良好的用户体验升华为令人难忘的细节之一。大多数实现方式都会选择第三方库——添加一个 SPM 依赖、调用一个函数就搞定了。但如果你想完全掌控物理效果、形状和时间呢?
在这篇文章中,我们将完全从零开始,使用 SwiftUI 的 Canvas 和 TimelineView 构建一个彩纸粒子系统。不用 SpriteKit,不用 UIKit 桥接,不用任何外部依赖——只需要数学和 SwiftUI。
为什么选择 Canvas 而不是 SpriteKit? SpriteKit 功能强大但比较重量级——它自带渲染管线、坐标系和生命周期管理。对于彩纸这样的装饰性覆盖层,Canvas 更加轻量:它直接在 SwiftUI 视图层级中渲染,能自然地与其他视图组合,并且通过 .drawingGroup() 获得 Metal 加速。结合 TimelineView 实现逐帧更新,它是自定义动画既需要高性能又需要原生体验的最佳选择。
读完本文后,你将拥有一个可复用的 ConfettiView,它能够:
- 模拟重力、空气阻力和终端速度
- 添加逼真的飘动(水平摆动)和 3D 翻转效果
- 以 60fps 渲染 30 个粒子,且 CPU 开销极低
- 通过简单的
@Binding切换来触发
粒子模型
物理模拟中的每个粒子都携带自己的状态。每个 ConfettiParticle 存储了在任意时间点计算其位置所需的全部信息,而不是从一个共享时钟来计算位置:
struct ConfettiParticle: Identifiable {
let id = UUID()
let color: Color
let size: CGSize
// 初始条件
let initialPosition: CGPoint
let initialVelocity: CGVector
let initialRotation: Double
let rotationSpeed: Double
let createdAt: Date
// 飘动(水平摆动)
let flutterAmplitude: CGFloat // 摆动幅度(25-45 点)
let flutterFrequency: CGFloat // 摆动频率(4-7 Hz)
let flutterPhase: CGFloat // 起始相位(0-2π)
// 3D 翻转效果
let flipFrequency: CGFloat // 翻转速度(3-6 Hz)
let flipPhase: CGFloat // 起始翻转角度(0-2π)
// 扩散
let spreadStrength: CGFloat // 向外漂移强度(8-18)
}为什么要将所有这些参数存储在每个粒子中,而不是全局计算?因为随机化才是让彩纸看起来自然的关键。每一片纸的重量略有不同,捕捉空气的方式也不同,翻转速率也各不相同。通过在生成时随机化参数,我们无需任何运行时随机性就能获得自然的运动效果——系统在创建后是完全确定性的。
物理基础
真正的彩纸不会以恒定速度下落。它向上发射、减速、达到顶点,然后下落——不断加速直到空气阻力与重力达到平衡,到达终端速度。我们用两个力来建模:
重力以恒定加速度将粒子向下拉:
gravity = 280 pts/s²空气阻力按比例抵消速度,产生指数衰减:
drag_coefficient = 1.6关键在于,受阻力作用的速度不是线性衰减的——它遵循指数曲线。因子 1 - e^(-drag × t) 给出了到时间 t 时初始速度已被"消耗"的比例:
let dragFactor = 1 - exp(-drag * t) // 范围从 0 到约 1终端速度是重力和阻力完全平衡时的速度:
let terminalVelocity = gravity / drag // 280 / 1.6 = 175 pts/s物理参数总结
| 常量 | 值 | 效果 |
|---|---|---|
| 重力 | 280 pts/s² | 向下的拉力 |
| 阻力 | 1.6 | 空气阻力(指数衰减) |
| 终端速度 | 175 pts/s | 最大下落速度 |
| 存活时间 | 1.5 秒 | 粒子存在的时长 |
位置计算
定义好物理模型后,我们可以计算每个粒子在任意时间 t 的位置:
水平位置
水平位置由三个分量组成——来自初始速度的减速漂移、正弦飘动和二次扩散:
let posX = particle.initialPosition.x
+ (particle.initialVelocity.dx / drag) * dragFactor // 减速漂移
+ particle.flutterAmplitude * sin( // 飘动摆动
particle.flutterFrequency * t + particle.flutterPhase
)
+ spreadOffset // 向外漂移第一项处理初始水平动量:(v₀ / drag) × (1 - e^(-drag×t)) 给出了在指数制动下的总行程距离。当 t → ∞ 时,它趋近于 v₀ / drag——初始推力所能到达的最大距离。
扩散偏移使粒子在下落时向外移动,使用 t² 实现平缓的加速:
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * t垂直位置
垂直分量结合了减速上升和终端速度下落:
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor // 减速上升
+ terminalVelocity * (t - dragFactor / drag) // 重力下拉第一项使初始向上速度(负的 dy)减速。第二项增加重力下落——注意 (t - dragFactor/drag) 的形式,它考虑了粒子在对抗阻力过渡到终端下降之前所花费的时间。
飘动与翻转
沿直线静态下落的粒子看起来毫无生气。两种振荡效果让它们栩栩如生:
飘动(水平摆动)
真正的纸质彩纸在下落时会捕捉气流,产生摆动运动。我们用正弦波来模拟:
flutterAmplitude * sin(flutterFrequency * t + flutterPhase)每个粒子获得随机化的参数:
- 振幅:25-45 点(摆动距离)
- 频率:4-7 Hz(摆动速度)
- 相位:0-2π(从周期的哪个位置开始)
相位的随机化至关重要——如果没有它,所有粒子会同步摆动,产生不自然的"波浪"效果。
3D 翻转(翻面效果)
纸质彩纸不仅会摆动——它还会端对端翻转,露出其薄薄的侧面。我们通过调制粒子的视觉宽度来模拟这种 3D 旋转:
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width * max(0.15, abs(cos(flipAngle)))当 cos(flipAngle) 接近 ±1 时,粒子显示为全宽(正面朝向)。当它趋近 0 时,宽度缩小到最低 15%——模拟纸张侧面朝向的效果。max(0.15, ...) 防止粒子在侧面角度时完全消失。
结合 rotationSpeed 的 2D 旋转,这创造了纸张在空中翻转的逼真视觉效果。
使用 Canvas 和 TimelineView 进行渲染
TimelineView 通过在每一帧请求重绘来驱动动画。Canvas 在一次绘制过程中渲染所有粒子——比创建 30 个单独的 SwiftUI 视图高效得多:
TimelineView(.animation) { timeline in
Canvas { context, _ in
let currentTime = timeline.date
for particle in particles {
let age = currentTime.timeIntervalSince(particle.createdAt)
guard age < particleLifetime else { continue }
let t = CGFloat(age)
let dragFactor = 1 - exp(-drag * t)
let terminalVelocity = gravity / drag
// 计算扩散
let spreadDirection: CGFloat =
particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset =
spreadDirection * particle.spreadStrength * t * t
// 位置
let posX = particle.initialPosition.x
+ (particle.initialVelocity.dx / drag) * dragFactor
+ particle.flutterAmplitude
* sin(particle.flutterFrequency * t + particle.flutterPhase)
+ spreadOffset
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor
+ terminalVelocity * (t - dragFactor / drag)
// 透明度淡出
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
? timeRemaining / fadeOutDuration
: 1.0
// 3D 翻转
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width
* max(0.15, abs(cos(flipAngle)))
// 绘制
var ctx = context
ctx.opacity = opacity
ctx.translateBy(x: posX, y: posY)
ctx.rotate(by: .degrees(
particle.initialRotation + particle.rotationSpeed * age
))
ctx.fill(
Path(CGRect(
x: -apparentWidth / 2,
y: -particle.size.height / 2,
width: apparentWidth,
height: particle.size.height
)),
with: .color(particle.color)
)
}
}
.drawingGroup()
}每个粒子的渲染流程是:平移到位置 -> 旋转 -> 用翻转调制的宽度绘制一个填充矩形。由于 Canvas 使用即时模式绘图 API,我们为每个粒子复制上下文以隔离变换。
.drawingGroup() 修饰符是关键——它告诉 SwiftUI 将 Canvas 展平为一个 Metal 支持的纹理,将渲染工作从 CPU 卸载到 GPU。
透明度与生命周期
突然出现和消失的粒子看起来很突兀。在 1.5 秒生命周期的最后 0.4 秒进行淡出过渡,可以实现平滑的消失效果:
private let particleLifetime: TimeInterval = 1.5
private let fadeOutDuration: TimeInterval = 0.4
// 在渲染循环中:
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
? timeRemaining / fadeOutDuration // 从 1.0 到 0.0 的线性衰减
: 1.0 // 淡出前保持完全不透明清理工作通过一个在生命周期结束后触发的异步任务来延迟执行:
.task(id: particles.isEmpty) {
try? await Task.sleep(for: .seconds(particleLifetime + 0.1))
removeExpiredParticles()
}
private func removeExpiredParticles() {
let now = Date()
particles.removeAll { particle in
now.timeIntervalSince(particle.createdAt) >= particleLifetime
}
}task(id:) 修饰符在 particles.isEmpty 变化时会重新触发——因此当一次爆发发生、粒子从空变为有值时,清理计时器就会启动。额外的 0.1 秒确保所有粒子在我们清除之前已完全过期。
生成粒子
createBurst 函数生成 30 个参数随机化的粒子,将它们分布在屏幕底部并向上发射:
private func createBurst(in size: CGSize) {
let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple]
let now = Date()
var newParticles: [ConfettiParticle] = []
for _ in 0 ..< 30 {
// 分布在屏幕宽度的 70% 范围内
let xSpread = CGFloat.random(in: size.width * 0.15 ... size.width * 0.85)
let position = CGPoint(x: xSpread, y: size.height + 20)
// 根据屏幕高度缩放速度,确保彩纸能到达顶部
let baseUpwardSpeed = size.height * 1.8
let upwardSpeed = -baseUpwardSpeed * CGFloat.random(in: 0.85 ... 1.1)
let horizontalSpeed = size.width * CGFloat.random(in: -0.3 ... 0.3)
// 三种形状变体:正方形、横向和纵向
let sizeType = Int.random(in: 0 ... 2)
let particleSize = switch sizeType {
case 0: CGSize(width: 10, height: 10)
case 1: CGSize(width: 12, height: 7)
default: CGSize(width: 7, height: 12)
}
let particle = ConfettiParticle(
color: colors.randomElement() ?? .blue,
size: particleSize,
initialPosition: position,
initialVelocity: CGVector(dx: horizontalSpeed, dy: upwardSpeed),
initialRotation: Double.random(in: 0 ... 360),
rotationSpeed: Double.random(in: -400 ... 400),
createdAt: now,
flutterAmplitude: CGFloat.random(in: 25 ... 45),
flutterFrequency: CGFloat.random(in: 4 ... 7),
flutterPhase: CGFloat.random(in: 0 ... .pi * 2),
flipFrequency: CGFloat.random(in: 3 ... 6),
flipPhase: CGFloat.random(in: 0 ... .pi * 2),
spreadStrength: CGFloat.random(in: 8 ... 18)
)
newParticles.append(particle)
}
particles.append(contentsOf: newParticles)
}有几个值得注意的设计选择:
- 屏幕相对速度:
size.height * 1.8确保彩纸无论设备尺寸如何都能到达顶部。在 iPhone SE 上,粒子以较低的绝对速度移动较短的距离;在 iPad 上,它们覆盖更大的范围。视觉效果保持一致。 - 三种形状变体:正方形(10x10)、横向(12x7)和纵向(7x12)的碎片创造了多样性。结合翻转效果,横向和纵向的碎片会产生明显不同的翻转模式。
- 批量创建:将所有粒子构建在一个局部数组中并一次性追加,避免了 30 次单独的状态变更。
整合
下面是完整的 ConfettiView——一个自包含的、通过 @Binding 触发的可复用组件:
struct ConfettiView: View {
@Binding var trigger: Bool
@State private var particles: [ConfettiParticle] = []
private let particleCount = 30
private let particleLifetime: TimeInterval = 1.5
private let gravity: CGFloat = 280.0
private let drag: CGFloat = 1.6
private let fadeOutDuration: TimeInterval = 0.4
var body: some View {
GeometryReader { geometry in
Group {
if !particles.isEmpty {
TimelineView(.animation) { timeline in
Canvas { context, _ in
let currentTime = timeline.date
for particle in particles {
let age = currentTime.timeIntervalSince(particle.createdAt)
guard age < particleLifetime else { continue }
let t = CGFloat(age)
let dragFactor = 1 - exp(-drag * t)
let terminalVelocity = gravity / drag
let spreadDirection: CGFloat =
particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset =
spreadDirection * particle.spreadStrength * t * t
let posX = particle.initialPosition.x
+ (particle.initialVelocity.dx / drag) * dragFactor
+ particle.flutterAmplitude
* sin(particle.flutterFrequency * t
+ particle.flutterPhase)
+ spreadOffset
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor
+ terminalVelocity * (t - dragFactor / drag)
let rotation =
particle.initialRotation + particle.rotationSpeed * age
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
? timeRemaining / fadeOutDuration : 1.0
let flipAngle =
particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width
* max(0.15, abs(cos(flipAngle)))
var ctx = context
ctx.opacity = opacity
ctx.translateBy(x: posX, y: posY)
ctx.rotate(by: .degrees(rotation))
ctx.fill(
Path(CGRect(
x: -apparentWidth / 2,
y: -particle.size.height / 2,
width: apparentWidth,
height: particle.size.height
)),
with: .color(particle.color)
)
}
}
.drawingGroup()
.task(id: particles.isEmpty) {
try? await Task.sleep(for: .seconds(particleLifetime + 0.1))
removeExpiredParticles()
}
}
}
}
.allowsHitTesting(false)
.onChange(of: trigger) { _, shouldTrigger in
if shouldTrigger {
createBurst(in: geometry.size)
trigger = false
}
}
}
}
// createBurst(in:) 和 removeExpiredParticles() 如上所示
}使用方式很简单——将它覆盖在任何视图上并切换绑定即可:
struct CelebrationScreen: View {
@State private var showConfetti = false
var body: some View {
ZStack {
VStack {
Text("恭喜!")
.font(.largeTitle)
Button("庆祝一下") {
showConfetti = true
}
.buttonStyle(.borderedProminent)
}
ConfettiView(trigger: $showConfetti)
}
}
}绑定会在 onChange 内部自动重置为 false,因此你可以再次将其设为 true 来触发多次爆发。
性能
三项技术确保动画在 60fps 下流畅运行:
.drawingGroup() —— 将 Canvas 合成到一个 Metal 支持的离屏缓冲区中。如果没有它,每一帧的绘制命令都会经过 CPU 合成管线。有了它,GPU 承担了主要的工作。这是最大的单项性能提升。
.allowsHitTesting(false) —— 告诉点击测试系统忽略整个覆盖层。如果没有它,SwiftUI 会在每次触摸事件时对每个粒子执行点在矩形内的测试。由于彩纸纯粹是装饰性的,没有必要参与点击测试。
条件渲染 —— if !particles.isEmpty 守卫在没有彩纸时完全从视图层级中移除 TimelineView。这意味着空闲时零开销——没有帧回调,没有 Canvas 分配,没有 GPU 纹理。
WARNING
避免同时生成超过约 100 个粒子。虽然 Canvas 效率很高,但每个粒子在每帧仍然需要三角函数运算(sin、cos、exp)。在 60fps 下有 100 个粒子,就是每秒 6,000 次三角函数调用。对于更大规模的效果,请考虑预计算查找表或改用 SpriteKit。
总结
我们仅使用 SwiftUI 原生组件构建了一个完整的彩纸粒子系统:
- 重力 + 阻力创造了逼真的减速和终端速度效果
- 飘动通过随机化的正弦波添加了自然的水平摆动
- 3D 翻转使用余弦调制的宽度模拟了纸张旋转
- Canvas + TimelineView 在单次 GPU 加速的绘制过程中渲染所有粒子
- 生命周期管理处理了淡出和清理,不会造成内存泄漏
这个物理模型有意保持简单——刚好足以看起来逼真,又不会有完整刚体模拟器的复杂性。在此基础上,你可以进一步扩展:
- 自定义形状:用
Path绘制圆形、星形或彩带来替代矩形 - 风力:为所有粒子添加随时间变化的水平力
- 拖尾:在每个粒子身后渲染逐渐消失的历史位置轨迹
- 触觉反馈:将爆发效果与
UIImpactFeedbackGenerator配对,提供触觉响应
完整实现不到 200 行 Swift 代码——足够简洁可以完全理解,又足够灵活可以为任何庆祝场景进行定制。

