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:
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.6O 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:
let dragFactor = 1 - exp(-drag * t) // Varia de 0 a ~1Velocidade terminal e a velocidade onde gravidade e arrasto se equilibram perfeitamente:
let terminalVelocity = gravity / drag // 280 / 1.6 = 175 pts/sResumo da Fisica
| Constante | Valor | Efeito |
|---|---|---|
| Gravidade | 280 pts/s² | Forca para baixo |
| Arrasto | 1.6 | Resistencia do ar (decaimento exponencial) |
| Velocidade terminal | 175 pts/s | Velocidade maxima de queda |
| Tempo de vida | 1.5 s | Duracao 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:
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 lateralO 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 t² para uma aceleracao suave:
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * tPosicao Vertical
O componente vertical combina subida desacelerada com queda em velocidade terminal:
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor // Subida desacelerada
+ terminalVelocity * (t - dragFactor / drag) // Puxao gravitacionalO 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:
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:
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:
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:
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 fadeA limpeza acontece de forma lazy por meio de uma task assincrona que dispara apos o tempo de vida expirar:
.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:
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.8garante 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:
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:
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
Pathpara 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
UIImpactFeedbackGeneratorpara 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.

