← Back to list

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…

DevTechie in DevTechie · 2025-04-02 09:27 · 2 claps · 5.2 min read paywalled
#swiftui #ios #ios18 #ios-animation #swiftui-animation
Open on Medium ↗
Wiki topics: EDU · Education & Learning 📱 · Mobile Development 🎬 · Film & Television

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

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.

Read for free at

[embed]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…www.devtechie.com

Here is what we will be building.

Would you prefer to watch it in video format? You can find it on YouTube.

[embed]

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

Visit us at — https://www.devtechie.com


메타데이터
post_id
adcbac07b3f1
slug
building-a-firework-animation-in-swiftui-a-hands-on-guide-adcbac07b3f1
url
https://medium.com/devtechie/building-a-firework-animation-in-swiftui-a-hands-on-guide-adcbac07b3f1
canonical_url
https://medium.com/devtechie/building-a-firework-animation-in-swiftui-a-hands-on-guide-adcbac07b3f1
author_url
https://medium.com/@devtechie
status
ok
fetched_at
2026-07-11 03:05:50