Skip to content

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:

swift
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.6

La 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:

swift
let dragFactor = 1 - exp(-drag * t)  // Va de 0 a ~1

La velocidad terminal es la velocidad donde la gravedad y la resistencia se equilibran perfectamente:

swift
let terminalVelocity = gravity / drag  // 280 / 1.6 = 175 pts/s

Resumen de fisica

ConstanteValorEfecto
Gravedad280 pts/s²Traccion hacia abajo
Resistencia1.6Resistencia del aire (decaida exponencial)
Velocidad terminal175 pts/sVelocidad maxima de caida
Tiempo de vida1.5 sCuanto 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:

swift
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 afuera

El 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 para una aceleracion gradual:

swift
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * t

Posicion vertical

El componente vertical combina la subida desacelerada con la caida a velocidad terminal:

swift
let posY = particle.initialPosition.y
    + (particle.initialVelocity.dy / drag) * dragFactor         // Subida desacelerada
    + terminalVelocity * (t - dragFactor / drag)                // Traccion gravitatoria

El 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:

swift
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:

swift
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:

swift
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:

swift
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 desvanecimiento

La limpieza ocurre de forma diferida mediante una tarea asincrona que se ejecuta despues de que el tiempo de vida expira:

swift
.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:

swift
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.8 asegura 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:

swift
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:

swift
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 Path para 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 UIImpactFeedbackGenerator para 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.