Animacion de confeti con fisica real en SwiftUI usando Canvas y TimelineView
Introduccion
Las animaciones de confeti son uno de esos detalles que transforman una buena experiencia de usuario en una memorable. La mayoria de las implementaciones recurren a un paquete de terceros: agregar una dependencia con SPM, llamar a una funcion, listo. Pero, que pasa si quieres control total sobre la fisica, las formas y el timing?
En este articulo, construiremos un sistema de particulas de confeti completamente desde cero usando Canvas y TimelineView de SwiftUI. Sin SpriteKit, sin interop con UIKit, sin paquetes externos. Solo matematicas y SwiftUI.
Por que Canvas en vez de SpriteKit? SpriteKit es poderoso pero pesado: trae su propio pipeline de renderizado, sistema de coordenadas y ciclo de vida. Para un overlay decorativo como el confeti, Canvas es mas liviano: renderiza directamente dentro de la jerarquia de vistas de SwiftUI, se compone de forma natural con otras vistas y obtiene aceleracion Metal a traves de .drawingGroup(). Combinado con TimelineView para actualizaciones cuadro por cuadro, es el punto ideal para animaciones personalizadas que necesitan sentirse nativas.
Al finalizar, tendras una ConfettiView reutilizable que:
- Simula gravedad, resistencia del aire y velocidad terminal
- Agrega aleteo realista (balanceo horizontal) y efectos de rotacion 3D
- Renderiza 30 particulas a 60fps con minimo consumo de CPU
- Se activa desde un simple toggle con
@Binding
El modelo de particula
Cada particula en una simulacion fisica lleva su propio estado. En lugar de calcular posiciones desde un reloj compartido, cada ConfettiParticle almacena todo lo necesario para calcular su posicion en cualquier momento:
struct ConfettiParticle: Identifiable {
let id = UUID()
let color: Color
let size: CGSize
// Condiciones iniciales
let initialPosition: CGPoint
let initialVelocity: CGVector
let initialRotation: Double
let rotationSpeed: Double
let createdAt: Date
// Aleteo (balanceo horizontal)
let flutterAmplitude: CGFloat // Que tan lejos se balancea (25-45 pts)
let flutterFrequency: CGFloat // Que tan rapido se balancea (4-7 Hz)
let flutterPhase: CGFloat // Fase inicial (0-2π)
// Efecto de giro 3D
let flipFrequency: CGFloat // Velocidad de giro (3-6 Hz)
let flipPhase: CGFloat // Angulo inicial de giro (0-2π)
// Dispersion
let spreadStrength: CGFloat // Intensidad de la deriva hacia afuera (8-18)
}Por que almacenar todos estos parametros por particula en lugar de calcularlos de forma global? Porque la aleatorizacion es lo que hace que el confeti se vea natural. Cada pedazo de papel tiene un peso ligeramente diferente, atrapa el aire de forma distinta y gira a su propio ritmo. Al aleatorizar los parametros en el momento de creacion, obtenemos un movimiento de apariencia organica sin ninguna aleatoriedad en tiempo de ejecucion; el sistema es completamente determinista despues de la creacion.
Fundamentos de fisica
El confeti real no cae a velocidad constante. Se lanza hacia arriba, desacelera, alcanza un punto maximo y luego cae, acelerando hasta que la resistencia del aire equilibra la gravedad en la velocidad terminal. Modelamos esto con dos fuerzas:
La gravedad tira de las particulas hacia abajo con una aceleracion constante:
gravedad = 280 pts/s²La resistencia del aire se opone a la velocidad de forma proporcional, creando una decaida exponencial:
coeficiente_de_resistencia = 1.6La idea clave es que la velocidad bajo resistencia no decae de forma lineal, sino que sigue una curva exponencial. El factor 1 - e^(-resistencia x t) nos da la fraccion de velocidad inicial que se ha "gastado" en el tiempo t:
let dragFactor = 1 - exp(-drag * t) // Va de 0 a ~1La velocidad terminal es la velocidad donde la gravedad y la resistencia se equilibran perfectamente:
let terminalVelocity = gravity / drag // 280 / 1.6 = 175 pts/sResumen de fisica
| Constante | Valor | Efecto |
|---|---|---|
| Gravedad | 280 pts/s² | Traccion hacia abajo |
| Resistencia | 1.6 | Resistencia del aire (decaida exponencial) |
| Velocidad terminal | 175 pts/s | Velocidad maxima de caida |
| Tiempo de vida | 1.5 s | Cuanto tiempo existen las particulas |
Calculo de posicion
Con el modelo de fisica definido, podemos calcular la posicion de cada particula en cualquier momento t:
Posicion horizontal
La posicion horizontal combina tres componentes: deriva desacelerada por la velocidad inicial, aleteo sinusoidal y dispersion cuadratica:
let posX = particle.initialPosition.x
+ (particle.initialVelocity.dx / drag) * dragFactor // Deriva desacelerada
+ particle.flutterAmplitude * sin( // Balanceo por aleteo
particle.flutterFrequency * t + particle.flutterPhase
)
+ spreadOffset // Deriva hacia afueraEl primer termino maneja el impulso horizontal inicial: (v₀ / resistencia) x (1 - e^(-resistencia x t)) da la distancia total recorrida bajo frenado exponencial. Cuando t -> infinito, esto se aproxima a v₀ / resistencia, la distancia maxima que el impulso inicial puede recorrer.
El offset de dispersion empuja las particulas hacia afuera mientras caen, usando t² para una aceleracion gradual:
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * tPosicion vertical
El componente vertical combina la subida desacelerada con la caida a velocidad terminal:
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor // Subida desacelerada
+ terminalVelocity * (t - dragFactor / drag) // Traccion gravitatoriaEl primer termino desacelera la velocidad inicial hacia arriba (dy negativo). El segundo termino agrega la caida gravitatoria; observa la forma (t - dragFactor/drag), que tiene en cuenta el tiempo que la particula pasa luchando contra la resistencia antes de transicionar al descenso terminal.
Aleteo y rotacion
Las particulas estaticas cayendo en linea recta se ven sin vida. Dos efectos de oscilacion les dan realismo:
Aleteo (balanceo horizontal)
El confeti real de papel atrapa corrientes de aire mientras cae, creando un movimiento de balanceo. Simulamos esto con una onda sinusoidal:
flutterAmplitude * sin(flutterFrequency * t + flutterPhase)Cada particula recibe parametros aleatorizados:
- Amplitud: 25-45 puntos (que tan lejos se balancea)
- Frecuencia: 4-7 Hz (que tan rapido se balancea)
- Fase: 0-2π (en que punto del ciclo comienza)
La aleatorizacion de fase es critica: sin ella, todas las particulas se balancearian en sincronia, creando un patron de "ola" antinatural.
Rotacion 3D (efecto de giro)
El confeti de papel no solo se balancea, tambien gira de extremo a extremo, revelando su perfil delgado. Simulamos esta rotacion 3D modulando el ancho aparente de la particula:
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width * max(0.15, abs(cos(flipAngle)))Cuando cos(flipAngle) esta cerca de +/-1, la particula aparece con su ancho completo (de frente). Conforme se acerca a 0, el ancho se reduce a un minimo de 15%, simulando el papel girandose de canto. El max(0.15, ...) evita que la particula desaparezca completamente en el angulo de canto.
Combinado con la rotacion 2D de rotationSpeed, esto crea una ilusion convincente de papel cayendo y girando en el aire.
Renderizado con Canvas y TimelineView
TimelineView controla la animacion solicitando redibujados en cada cuadro. Canvas renderiza todas las particulas en un solo pase de dibujo, mucho mas eficiente que crear 30 vistas individuales de 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
// Calcular dispersion
let spreadDirection: CGFloat =
particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset =
spreadDirection * particle.spreadStrength * t * t
// Posicion
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)
// Desvanecimiento de opacidad
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
? timeRemaining / fadeOutDuration
: 1.0
// Giro 3D
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width
* max(0.15, abs(cos(flipAngle)))
// Dibujar
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()
}El pipeline de renderizado por particula es: trasladar a la posicion, rotar y dibujar un rectangulo relleno con el ancho modulado por el giro. Como Canvas usa una API de dibujo en modo inmediato, copiamos el contexto para cada particula para aislar las transformaciones.
El modificador .drawingGroup() es clave: le indica a SwiftUI que aplane el canvas en una textura respaldada por Metal, descargando el renderizado del CPU al GPU.
Opacidad y ciclo de vida
Las particulas que aparecen y desaparecen de golpe se ven bruscas. Una rampa de desvanecimiento en los ultimos 0.4 segundos del tiempo de vida de 1.5 segundos crea una desaparicion suave:
private let particleLifetime: TimeInterval = 1.5
private let fadeOutDuration: TimeInterval = 0.4
// En el ciclo de renderizado:
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
? timeRemaining / fadeOutDuration // Rampa lineal de 1.0 a 0.0
: 1.0 // Opacidad completa antes del desvanecimientoLa limpieza ocurre de forma diferida mediante una tarea asincrona que se ejecuta despues de que el tiempo de vida expira:
.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
}
}El modificador task(id:) se reactiva cada vez que particles.isEmpty cambia, asi que cuando se dispara una rafaga y las particulas pasan de vacio a poblado, el temporizador de limpieza se inicia. Los 0.1 segundos extra aseguran que todas las particulas hayan expirado completamente antes de hacer la limpieza.
Generacion de particulas
La funcion createBurst genera 30 particulas con parametros aleatorizados, posicionandolas a lo largo de la parte inferior de la pantalla y lanzandolas hacia arriba:
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 {
// Distribuir en el 70% central del ancho de pantalla
let xSpread = CGFloat.random(in: size.width * 0.15 ... size.width * 0.85)
let position = CGPoint(x: xSpread, y: size.height + 20)
// Escalar velocidad segun la altura de pantalla para que el confeti llegue arriba
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)
// Tres variantes de forma: cuadrada, ancha y alta
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)
}Algunas decisiones de diseno que vale la pena destacar:
- Velocidades relativas a la pantalla:
size.height * 1.8asegura que el confeti llegue a la parte superior sin importar el tamano del dispositivo. En un iPhone SE, las particulas recorren distancias mas cortas a menor velocidad absoluta; en un iPad, cubren mas terreno. El efecto visual se mantiene consistente. - Tres variantes de forma: Las piezas cuadradas (10x10), horizontales (12x7) y verticales (7x12) crean variedad. Combinadas con el efecto de giro, las piezas horizontales y verticales generan patrones de rotacion visiblemente distintos.
- Creacion por lotes: Construir todas las particulas en un arreglo local y agregarlas de una sola vez evita 30 mutaciones de estado separadas.
Integracion completa
Aqui esta la ConfettiView completa: un componente autocontenido y reutilizable que se activa mediante un @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:) y removeExpiredParticles() como se muestran arriba
}Su uso es sencillo: superponlo sobre cualquier vista y activa el binding:
struct CelebrationScreen: View {
@State private var showConfetti = false
var body: some View {
ZStack {
VStack {
Text("Felicidades!")
.font(.largeTitle)
Button("Celebrar") {
showConfetti = true
}
.buttonStyle(.borderedProminent)
}
ConfettiView(trigger: $showConfetti)
}
}
}El binding se restablece a false dentro de onChange, asi que puedes disparar multiples rafagas configurandolo como true nuevamente.
Rendimiento
Tres tecnicas mantienen esto funcionando sin problemas a 60fps:
.drawingGroup() -- Compone el Canvas en un buffer offscreen respaldado por Metal. Sin el, los comandos de dibujo de cada cuadro pasan por el pipeline de composicion del CPU. Con el, el GPU se encarga del trabajo pesado. Esta es la mejora de rendimiento mas significativa.
.allowsHitTesting(false) -- Le indica al sistema de hit-testing que ignore todo el overlay. Sin esto, SwiftUI ejecutaria pruebas de punto-en-rectangulo contra cada particula en cada evento tactil. Como el confeti es puramente decorativo, no hay razon para participar en el hit testing.
Renderizado condicional -- La guarda if !particles.isEmpty elimina el TimelineView de la jerarquia por completo cuando no hay confeti. Esto significa costo cero en reposo: sin callbacks de cuadro, sin asignacion de Canvas, sin textura de GPU.
WARNING
Evita generar mas de ~100 particulas simultaneamente. Aunque Canvas es eficiente, cada particula aun requiere trigonometria por cuadro (sin, cos, exp). A 60fps con 100 particulas, eso son 6,000 llamadas trigonometricas por segundo. Para efectos mas grandes, considera pre-calcular tablas de busqueda o usar SpriteKit en su lugar.
Conclusion
Construimos un sistema completo de particulas de confeti usando unicamente primitivas de SwiftUI:
- Gravedad + resistencia crean una desaceleracion realista y velocidad terminal
- Aleteo agrega balanceo horizontal organico mediante ondas sinusoidales aleatorizadas
- Rotacion 3D simula el giro del papel usando ancho modulado por coseno
- Canvas + TimelineView renderizan todas las particulas en un solo pase de dibujo acelerado por GPU
- Gestion del ciclo de vida maneja el desvanecimiento y la limpieza sin fugas de memoria
El modelo de fisica es intencionalmente simple: lo suficiente para verse convincente sin la complejidad de un simulador completo de cuerpos rigidos. A partir de aqui, podrias extenderlo con:
- Formas personalizadas: Reemplazar los rectangulos con dibujos de
Pathpara circulos, estrellas o cintas - Viento: Agregar una fuerza horizontal variable en el tiempo a todas las particulas
- Estelas: Renderizar un historial desvanecido de posiciones anteriores detras de cada particula
- Retroalimentacion haptica: Combinar la rafaga con
UIImpactFeedbackGeneratorpara una respuesta tactil
La implementacion completa tiene menos de 200 lineas de Swift, lo suficientemente pequena para entenderla por completo y lo suficientemente flexible para personalizarla en cualquier celebracion.

