Building a Firework Animation in SwiftUI: A Hands-On Guide

  • Mar 24, 2025

Building a Firework Animation in SwiftUI: A Hands-On Guide

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