- Mar 24, 2025
Building a Firework Animation in SwiftUI: A Hands-On Guide
- DevTechie
- SwiftUI
Building a Firework Animation in SwiftUI: A Hands-On Guide
Creating engaging animations in SwiftUI is both fun and educational. In this article, we will explore how to build a firework effect that allows users to draw a path, light a fuse, and trigger an explosion at the starting point of their drawing. This project will showcase several SwiftUI and app dev concepts, including Observable, @Binding properties, Shaperendering, and animation techniques.
Here is what we will be building.
Models
Let’s start with models. We will need two models for this project.
PathPoint: this model stores individual points of the user-drawn path.
struct PathPoint: Identifiable {
let id = UUID()
var point: CGPoint
}Particle: this model represents an individual firework particle with properties for position, velocity, color, size, and opacity.
struct Particle: Identifiable {
let id = UUID()
var position: CGPoint
var velocity: CGPoint
var color: Color
var size: CGFloat
var opacity: Double = 1.0
}View Model
Next we will create FireworksViewModel which will be decorated with Observable macro. This view model will be responsible for hosting the business logic for
Drawing the Path: Users can draw a path by touching the screen, and the points are stored in pathPoints.
Lighting the Fuse: Once the user finishes drawing, the fuse starts burning along the path using fuseProgress.
Triggering the Explosion: When the fuse reaches the start of the path, triggerExplosion() generates particles that expand outward.
Animating Particles: A timer updates the position and opacity of each particle, simulating a realistic explosion.
import Observation
@Observable
class FireworkViewModel {
var fuseProgress: CGFloat = 0
var showExplosion = false
var particles: [Particle] = []
var pathPoints: [PathPoint] = []
var isDrawing = true
private var timer: AnyCancellable?
private var explosionTimer: AnyCancellable?We will create a few helper functions to add path points, start the fuse, finish drawing state, trigger the animation, and reset the state.
import Observation
@Observable
class FireworkViewModel {
...
func addPoint(_ point: CGPoint) {
if isDrawing {
pathPoints.append(PathPoint(point: point))
}
}
func finishDrawing() {
isDrawing = false
startFuse()
}
func reset() {
isDrawing = true
pathPoints = []
fuseProgress = 0
showExplosion = false
particles = []
timer?.cancel()
explosionTimer?.cancel()
}
func startFuse() {
guard !pathPoints.isEmpty else { return }
fuseProgress = 0
showExplosion = false
particles = []
timer = Timer.publish(every: 0.01, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
if self.fuseProgress < 1.0 {
self.fuseProgress += 0.004
} else {
self.timer?.cancel()
self.triggerExplosion()
}
}
}
func triggerExplosion() {
showExplosion = true
generateParticles()
explosionTimer = Timer.publish(every: 0.016, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
self.updateParticles()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.explosionTimer?.cancel()
}
}We will create function to get the position for explosion, which will be the first point in the pathPoints collection.
func getExplosionPosition() -> CGPoint {
return pathPoints.first?.point ?? .zero
}Next, add a function called generateParticles to generate the explosion particles.
private func generateParticles() {
let colors: [Color] = [.red, .orange, .yellow, .blue, .purple, .pink]
let particleCount = 150
let explosionPosition = getExplosionPosition()
for _ in 0..<particleCount {
let angle = Double.random(in: 0...2 * .pi)
let speed = Double.random(in: 1...5)
let size = CGFloat.random(in: 2...8)
let particle = Particle(
position: explosionPosition,
velocity: CGPoint(
x: cos(angle) * speed,
y: sin(angle) * speed
),
color: colors.randomElement() ?? .red,
size: size
)
particles.append(particle)
}
}Let’s add another function to update the particle position and remove particles at the end of the explosion.
private func updateParticles() {
for i in 0..<particles.count {
var particle = particles[i]
particle.position.x += particle.velocity.x
particle.position.y += particle.velocity.y
particle.velocity.y += 0.05
particle.opacity -= 0.01
particles[i] = particle
}
particles.removeAll { $0.opacity <= 0 }
}We are ready to jump on views. First, we will create a custom shape and render path on all the user-drawn points.
struct CustomPathShape: Shape {
let points: [PathPoint]
func path(in rect: CGRect) -> Path {
var path = Path()
guard !points.isEmpty else { return path }
path.move(to: points[0].point)
for i in 1..<points.count {
path.addLine(to: points[i].point)
}
return path
}
}Next, we will create flame view
struct FlameView: View {
@State private var flameAnimation = false
var body: some View {
ZStack {
Image(systemName: "flame.fill")
.foregroundColor(.red)
.offset(y: 2)
.opacity(0.8)
.scaleEffect(flameAnimation ? 0.5 : 1.0)
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.scaleEffect(0.7)
.offset(y: -2)
.opacity(0.9)
.scaleEffect(flameAnimation ? 1.5 : 0.9)
Image(systemName: "flame.fill")
.foregroundColor(.yellow)
.scaleEffect(0.5)
.offset(y: -5)
.scaleEffect(flameAnimation ? 0.3 : 1.1)
}
.onAppear {
withAnimation(Animation.linear.repeatForever(autoreverses: true)) {
flameAnimation.toggle()
}
}
}
}We will use the drag gesture to draw on the screen. We will also use a CustomPathShape view to provide the drawing area. Based on the view model’s drawing state, we will trace back the path on the points we’ve tracked during the drawing process.
struct DrawingAreaView: View {
@Binding var viewModel: FireworkViewModel
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
if viewModel.isDrawing {
VStack {
Spacer()
Text("Draw a path with your finger")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(10)
Spacer().frame(height: 100)
}
}
CustomPathShape(points: viewModel.pathPoints)
.stroke(viewModel.isDrawing ? Color.gray : Color.brown.opacity(0.5), lineWidth: 3)
if !viewModel.isDrawing {
ZStack {
CustomPathShape(points: viewModel.pathPoints)
.trim(from: 1.0 - viewModel.fuseProgress, to: 1.0)
.stroke(Color.yellow.opacity(0.5), lineWidth: 8)
.blur(radius: 4)
CustomPathShape(points: viewModel.pathPoints)
.trim(from: 1.0 - viewModel.fuseProgress, to: 1.0)
.stroke(Color.orange.opacity(0.7), lineWidth: 5)
.blur(radius: 2)
CustomPathShape(points: viewModel.pathPoints)
.trim(from: 1.0 - viewModel.fuseProgress, to: 1.0)
.stroke(Color.orange, lineWidth: 3)
}
}
if !viewModel.isDrawing && viewModel.fuseProgress < 1.0 {
FlameView()
.frame(width: 20, height: 30)
.position(viewModel.getCurrentBurningPosition(geometry: geometry))
}
if viewModel.showExplosion {
ForEach(viewModel.particles) { particle in
Circle()
.fill(particle.color)
.frame(width: particle.size, height: particle.size)
.position(particle.position)
.opacity(particle.opacity)
}
}
VStack {
Spacer()
HStack {
if viewModel.isDrawing {
Button(action: {
viewModel.finishDrawing()
}) {
Text("Light the Fuse! 🔥")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.red)
.cornerRadius(10)
}
.disabled(viewModel.pathPoints.isEmpty)
.opacity(viewModel.pathPoints.isEmpty ? 0.5 : 1.0)
} else {
Button(action: {
viewModel.reset()
}) {
Text("Draw Again ✏️")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(10)
}
}
}
.padding(.bottom, 40)
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
if viewModel.isDrawing {
viewModel.addPoint(value.location)
}
}
)
}
}
}Let’s add another function to the FireworksViewModel to track the current burning point in the fuse process while tracing back so we can align the flame to the tip of the spark.
func getCurrentBurningPosition(geometry: GeometryProxy) -> CGPoint {
guard !pathPoints.isEmpty else { return .zero }
var totalLength: CGFloat = 0
var segmentLengths: [CGFloat] = []
for i in 0..<pathPoints.count-1 {
let start = pathPoints[i]
let end = pathPoints[i+1]
let length = sqrt(pow(end.point.x - start.point.x, 2) + pow(end.point.y - start.point.y, 2))
segmentLengths.append(length)
totalLength += length
}
let currentDistance = totalLength * (1.0 - fuseProgress)
var distanceTraveled: CGFloat = 0
for i in 0..<segmentLengths.count {
let segmentLength = segmentLengths[i]
if distanceTraveled + segmentLength >= currentDistance {
let start = pathPoints[i]
let end = pathPoints[i+1]
let segmentProgress = (currentDistance - distanceTraveled) / segmentLength
let x = start.point.x + (end.point.x - start.point.x) * segmentProgress
let y = start.point.y + (end.point.y - start.point.y) * segmentProgress
return CGPoint(x: x, y: y)
}
distanceTraveled += segmentLength
}
return pathPoints.last?.point ?? CGPoint.zero
}We will put this all together in FireworksDriverView
struct FireworksDriverView: View {
@State private var viewModel = FireworkViewModel()
var body: some View {
DrawingAreaView(viewModel: $viewModel)
}
}Build and run

