Skip to content

Animacao de Confete com Fisica Real em SwiftUI usando Canvas e TimelineView

Introducao

Animacoes de confete sao aqueles detalhes que transformam uma boa experiencia de usuario em algo memoravel. A maioria das implementacoes recorre a um pacote de terceiros — adicionar uma dependencia via SPM, chamar uma funcao, pronto. Mas e se voce quiser controle total sobre a fisica, os formatos, o timing?

Neste artigo, vamos construir um sistema de particulas de confete inteiramente do zero usando Canvas e TimelineView do SwiftUI. Sem SpriteKit, sem interoperabilidade com UIKit, sem pacotes externos. Apenas matematica e SwiftUI.

Por que Canvas em vez de SpriteKit? O SpriteKit e poderoso, mas pesado — ele traz seu proprio pipeline de renderizacao, sistema de coordenadas e ciclo de vida. Para um overlay decorativo como confete, o Canvas e mais leve: ele renderiza diretamente na hierarquia de views do SwiftUI, se compoe naturalmente com outras views e recebe aceleracao Metal via .drawingGroup(). Combinado com TimelineView para atualizacoes quadro a quadro, e o ponto ideal para animacoes customizadas que precisam parecer nativas.

Ao final, voce tera uma ConfettiView reutilizavel que:

  • Simula gravidade, arrasto do ar e velocidade terminal
  • Adiciona oscilacao realista (balanco horizontal) e efeitos de rotacao 3D
  • Renderiza 30 particulas a 60fps com uso minimo de CPU
  • E acionada por um simples toggle via @Binding

O Modelo de Particula

Cada particula em uma simulacao fisica carrega seu proprio estado. Em vez de calcular posicoes a partir de um relogio compartilhado, cada ConfettiParticle armazena tudo o que e necessario para calcular sua posicao em qualquer momento:

swift
struct ConfettiParticle: Identifiable {
    let id = UUID()
    let color: Color
    let size: CGSize

    // Condicoes iniciais
    let initialPosition: CGPoint
    let initialVelocity: CGVector
    let initialRotation: Double
    let rotationSpeed: Double
    let createdAt: Date

    // Oscilacao (balanco horizontal)
    let flutterAmplitude: CGFloat   // Amplitude do balanco (25-45 pts)
    let flutterFrequency: CGFloat   // Velocidade do balanco (4-7 Hz)
    let flutterPhase: CGFloat       // Fase inicial (0-2pi)

    // Efeito de giro 3D
    let flipFrequency: CGFloat      // Velocidade de rotacao (3-6 Hz)
    let flipPhase: CGFloat          // Angulo inicial de giro (0-2pi)

    // Dispersao
    let spreadStrength: CGFloat     // Intensidade da deriva lateral (8-18)
}

Por que armazenar todos esses parametros por particula em vez de calcula-los globalmente? Porque a randomizacao e o que faz o confete parecer natural. Cada pedaco de papel tem um peso ligeiramente diferente, captura o ar de forma diferente e gira em seu proprio ritmo. Ao randomizar parametros no momento da criacao, obtemos um movimento de aparencia organica sem nenhuma aleatoriedade em tempo de execucao — o sistema e totalmente deterministico apos a criacao.

Fundamentos de Fisica

Confete real nao cai em velocidade constante. Ele e lancado para cima, desacelera, atinge o pico e entao cai — acelerando ate que a resistencia do ar equilibre a gravidade na velocidade terminal. Modelamos isso com duas forcas:

Gravidade puxa as particulas para baixo com aceleracao constante:

gravidade = 280 pts/s²

Arrasto do ar se opoe a velocidade proporcionalmente, criando um decaimento exponencial:

coeficiente_de_arrasto = 1.6

O ponto-chave e que a velocidade sob arrasto nao decai linearmente — ela segue uma curva exponencial. O fator 1 - e^(-arrasto x t) nos da a fracao da velocidade inicial que foi "gasta" no tempo t:

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

Velocidade terminal e a velocidade onde gravidade e arrasto se equilibram perfeitamente:

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

Resumo da Fisica

ConstanteValorEfeito
Gravidade280 pts/s²Forca para baixo
Arrasto1.6Resistencia do ar (decaimento exponencial)
Velocidade terminal175 pts/sVelocidade maxima de queda
Tempo de vida1.5 sDuracao de cada particula

Calculo de Posicao

Com o modelo fisico definido, podemos calcular a posicao de cada particula em qualquer instante t:

Posicao Horizontal

A posicao horizontal combina tres componentes — deriva desacelerada da velocidade inicial, oscilacao senoidal e dispersao quadratica:

swift
let posX = particle.initialPosition.x
    + (particle.initialVelocity.dx / drag) * dragFactor        // Deriva desacelerada
    + particle.flutterAmplitude * sin(                          // Balanco oscilatório
        particle.flutterFrequency * t + particle.flutterPhase
      )
    + spreadOffset                                              // Deriva lateral

O primeiro termo lida com o momento horizontal inicial: (v0 / arrasto) x (1 - e^(-arrasto x t)) fornece a distancia total percorrida sob frenagem exponencial. Quando t -> infinito, isso se aproxima de v0 / arrasto — a distancia maxima que o impulso inicial pode alcancar.

O offset de dispersao empurra as particulas para fora conforme caem, usando para uma aceleracao suave:

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

Posicao Vertical

O componente vertical combina subida desacelerada com queda em velocidade terminal:

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

O primeiro termo desacelera a velocidade inicial ascendente (dy negativo). O segundo termo adiciona a queda gravitacional — observe a forma (t - dragFactor/drag), que leva em conta o tempo que a particula gasta lutando contra o arrasto antes de transicionar para a descida terminal.

Oscilacao e Rotacao

Particulas estaticas caindo em linhas retas parecem sem vida. Dois efeitos de oscilacao dao vida a elas:

Oscilacao (Balanco Horizontal)

Confete real de papel captura correntes de ar enquanto cai, criando um movimento de balanco. Simulamos isso com uma onda senoidal:

swift
flutterAmplitude * sin(flutterFrequency * t + flutterPhase)

Cada particula recebe parametros randomizados:

  • Amplitude: 25-45 pontos (o quanto balanca)
  • Frequencia: 4-7 Hz (a velocidade do balanco)
  • Fase: 0-2pi (em que ponto do ciclo comeca)

A randomizacao da fase e fundamental — sem ela, todas as particulas balancariam em sincronia, criando um padrao de "onda" artificial.

Rotacao 3D (Efeito de Giro)

Confete de papel nao apenas balanca — ele gira de ponta a ponta, revelando seu perfil fino. Simulamos essa rotacao 3D modulando a largura aparente da particula:

swift
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width * max(0.15, abs(cos(flipAngle)))

Quando cos(flipAngle) esta proximo de +/-1, a particula aparece em largura total (de frente). Conforme se aproxima de 0, a largura diminui para um minimo de 15% — simulando o papel virando de lado. O max(0.15, ...) impede que a particula desapareca completamente no angulo lateral.

Combinado com a rotacao 2D de rotationSpeed, isso cria uma ilusao convincente de papel girando pelo ar.

Renderizacao com Canvas e TimelineView

TimelineView conduz a animacao solicitando redesenhos a cada frame. Canvas renderiza todas as particulas em uma unica passada de desenho — muito mais eficiente do que criar 30 views SwiftUI individuais:

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 dispersao
            let spreadDirection: CGFloat =
                particle.initialVelocity.dx >= 0 ? 1 : -1
            let spreadOffset =
                spreadDirection * particle.spreadStrength * t * t

            // Posicao
            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)

            // Fade-out de opacidade
            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)))

            // Desenhar
            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()
}

O pipeline de renderizacao por particula e: transladar para a posicao -> rotacionar -> desenhar um retangulo preenchido com a largura modulada pelo giro. Como o Canvas usa uma API de desenho em modo imediato, copiamos o contexto para cada particula para isolar as transformacoes.

O modificador .drawingGroup() e fundamental — ele instrui o SwiftUI a achatar o canvas em uma textura com suporte Metal, transferindo a renderizacao da CPU para a GPU.

Opacidade e Ciclo de Vida

Particulas que aparecem e desaparecem abruptamente causam uma impressao brusca. Uma rampa de fade-out nos ultimos 0.4 segundos do tempo de vida de 1.5 segundos cria um desaparecimento suave:

swift
private let particleLifetime: TimeInterval = 1.5
private let fadeOutDuration: TimeInterval = 0.4

// No loop de renderizacao:
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
    ? timeRemaining / fadeOutDuration  // Rampa linear de 1.0 a 0.0
    : 1.0                             // Opacidade total antes do fade

A limpeza acontece de forma lazy por meio de uma task assincrona que dispara apos o tempo de vida expirar:

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
    }
}

O modificador task(id:) e re-acionado sempre que particles.isEmpty muda — entao quando uma explosao dispara e as particulas passam de vazio para populado, o timer de limpeza inicia. Os 0.1 segundos extras garantem que todas as particulas tenham expirado completamente antes da remocao.

Criando as Particulas

A funcao createBurst gera 30 particulas com parametros randomizados, posicionando-as ao longo da parte inferior da tela e lancando-as para cima:

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 {
        // Distribui ao longo de 70% da largura inferior da tela
        let xSpread = CGFloat.random(in: size.width * 0.15 ... size.width * 0.85)
        let position = CGPoint(x: xSpread, y: size.height + 20)

        // Escala a velocidade pela altura da tela para que o confete alcance o topo
        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 formato: quadrado, largo e alto
        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)
}

Algumas decisoes de design que valem destacar:

  • Velocidades relativas a tela: size.height * 1.8 garante que o confete alcance o topo independentemente do tamanho do dispositivo. Em um iPhone SE, as particulas percorrem distancias menores em velocidade absoluta mais baixa; em um iPad, cobrem mais espaco. O efeito visual permanece consistente.
  • Tres variantes de formato: Pecas quadradas (10x10), paisagem (12x7) e retrato (7x12) criam variedade. Combinadas com o efeito de giro, as pecas paisagem e retrato criam padroes de rotacao distintamente diferentes.
  • Criacao em lote: Construir todas as particulas em um array local e adicionar de uma vez evita 30 mutacoes de estado separadas.

Juntando Tudo

Aqui esta a ConfettiView completa — um componente autonomo e reutilizavel acionado por um @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:) e removeExpiredParticles() conforme mostrado acima
}

O uso e direto — sobreponha em qualquer view e alterne o binding:

swift
struct CelebrationScreen: View {
    @State private var showConfetti = false

    var body: some View {
        ZStack {
            VStack {
                Text("Parabens!")
                    .font(.largeTitle)

                Button("Celebrar") {
                    showConfetti = true
                }
                .buttonStyle(.borderedProminent)
            }

            ConfettiView(trigger: $showConfetti)
        }
    }
}

O binding se reseta para false dentro do onChange, entao voce pode disparar multiplas explosoes configurando-o como true novamente.

Performance

Tres tecnicas mantem tudo rodando suavemente a 60fps:

.drawingGroup() — Compoe o Canvas em um buffer offscreen com suporte Metal. Sem ele, os comandos de desenho de cada frame passam pelo pipeline de composicao da CPU. Com ele, a GPU faz o trabalho pesado. Essa e a maior vitoria de performance.

.allowsHitTesting(false) — Informa ao sistema de hit-testing para ignorar todo o overlay. Sem isso, o SwiftUI executaria testes de ponto-em-retangulo contra cada particula em cada evento de toque. Como o confete e puramente decorativo, nao ha razao para participar do hit testing.

Renderizacao condicional — O guard if !particles.isEmpty remove o TimelineView da hierarquia inteiramente quando nao ha confete. Isso significa custo zero quando ocioso — sem callbacks de frame, sem alocacao de Canvas, sem textura na GPU.

WARNING

Evite gerar mais de ~100 particulas simultaneamente. Embora o Canvas seja eficiente, cada particula ainda requer trigonometria por frame (sin, cos, exp). A 60fps com 100 particulas, sao 6.000 chamadas trigonometricas por segundo. Para efeitos maiores, considere pre-computar tabelas de consulta ou usar SpriteKit.

Conclusao

Construimos um sistema completo de particulas de confete usando apenas primitivas do SwiftUI:

  • Gravidade + arrasto criam desaceleracao realista e velocidade terminal
  • Oscilacao adiciona balanco horizontal organico via ondas senoidais randomizadas
  • Rotacao 3D simula a rotacao do papel usando largura modulada por cosseno
  • Canvas + TimelineView renderizam todas as particulas em uma unica passada de desenho acelerada por GPU
  • Gerenciamento de ciclo de vida cuida do fade-out e limpeza sem vazamento de memoria

O modelo fisico e intencionalmente simples — apenas o suficiente para parecer convincente sem a complexidade de um simulador completo de corpo rigido. A partir daqui, voce poderia estender com:

  • Formatos customizados: Substituir retangulos por desenhos com Path para circulos, estrelas ou fitas
  • Vento: Adicionar uma forca horizontal variavel no tempo para todas as particulas
  • Rastros: Renderizar um historico esmaecido de posicoes anteriores atras de cada particula
  • Feedback haptico: Combinar a explosao com UIImpactFeedbackGenerator para uma resposta tatil

A implementacao completa tem menos de 200 linhas de Swift — pequena o suficiente para entender completamente, flexivel o suficiente para customizar para qualquer celebracao.