Magical Particle Effects with SwiftUI Canvas

In one of the previous posts, I shared a simple way of Creating particle effects in SwiftUI. The approach is super easy and utilizes the power of viewModifiers, but I would not recommend it for production use as it is performance-greedy when having a bigger amount of particles in place (because each particle is a single view)

In this post, I will introduce you to an alternate and better approach - rendering the particles with the Canvas view. So let’s get into it 💪.

Setup

We will start with the following view outline:

struct ParticleCanvasView: View {
    
    var body: some View {
        TimelineView(.animation) { context in
            Canvas { context, size in
	       let particleSymbol = context.resolveSymbol(id: 0)!
                let position = CGPoint(x: size.width/2, y: size.height/2)                    
                context.draw(particleSymbol, at: position, anchor: .center)
            } symbols: {
                SingleParticleView()
                    .tag(0)
            }
        }
    }
}

This view features an outer TimelineView that ensures its content (inner view) is regularly re-drawn. (note the .animation parameter allowing the system to decide the optimal refresh rate) The content here is a Canvas view. Those of you who come from good old UIKit days might be already familiar with the concept of the drawing context. In simple words, we get a canvas area with the view dimensions and we can draw/rasterize various entities on it - like shapes and images.

In our case, we will draw a single particle represented with a SingleParticleView. Please note, how the SingleParticleView is being used as a drawing element. It is added to a symbols parameter allowing SwiftUI to pre-render it and thus be very performant later in the drawing calls - thus an ideal candidate for the many particles in the place ;)

At this moment, let’s just set the SingleParticleView as an orange dot, but we will tune it soon

struct SingleParticleView: View {
    var body: some View {
        Circle().fill(Color.orange)
            .frame(width:35, height:35)
    }
}

So far, we have managed to draw a small orange dot, but it is about to change soon ;)

image1

I like to move it

Now, let’s move that particle.

I will build here a fire-ish effect blending multiple upwards moving particles - so as a good start let’s periodically move the single particle up from the canvas bottom:

struct ParticleCanvasView: View {
    let movementDuration = 2.0
    
    var body: some View {
        TimelineView(.animation) { context in
            let timeInterval = context.date.timeIntervalSinceReferenceDate;
            
            let time = timeInterval.truncatingRemainder(dividingBy: movementDuration)/movementDuration
            
            Canvas { context, size in
                let particleSymbol = context.resolveSymbol(id: 0)!
                let position = CGPoint(x: size.width/2, y: (1-time)*size.height)
                    
                context.draw(particleSymbol, at: position, anchor: .center)
            } symbols: {
                SingleParticleView()
                    .tag(0)
            }
        }
    }
}

You can see, that I am controlling the upwards movement with the time variable. What exactly is it in this context? Well, the timeline view already gives us access to the time property, but for our use, I want to have something normalized that can be easily bound with the particle movement. I want the particle movement to take exactly 2 seconds (see movementDuration) so the code computes time as a truncating remainder, making sure it will periodically grow from 0 to 1 forever. As you can see in the following video:

Remember goniometry?

As a next step, we will upgrade the movement from the simple linear to something more firey :). My perception of fire movement is that it is waving, so let me change the code to move the particle along a cosinus wave whose amplitude is smaller the higher the particle is:

struct ParticleCanvasView: View {
    let movementDuration = 2.0
    
    func particlePosition(timeInterval: Double, canvasSize: CGSize) -> CGPoint {
        let time = timeInterval.truncatingRemainder(dividingBy: movementDuration)/movementDuration
        let rotations:CGFloat = 3
        let amplitude: CGFloat = 0.1+0.8*(1-time)
        let x = canvasSize.width/2 + cos(rotations*time*CGFloat.pi*2)*canvasSize.width/2*amplitude
        
        return CGPoint(x: x, y: (1-time)*canvasSize.height)
    }
    
    var body: some View {
        TimelineView(.animation) { context in
            let timeInterval = context.date.timeIntervalSinceReferenceDate;
            
            Canvas { context, size in
                let particleSymbol = context.resolveSymbol(id: 0)!
                let position = particlePosition(timeInterval: timeInterval, canvasSize: size)
                
                context.draw(particleSymbol, at: position, anchor: .center)
            } symbols: {
                SingleParticleView()
                    .tag(0)
            }
        }
    }
}

please note the position computation was moved to a separate function so the Canvas content remains clean.

Make it many

At this point, I am quite happy with the movement. I am sure we will fine-tune the constants here and there, but that can come later. Now, we want to draw more particles so let’s wrap the drawing into the for-cycle like this:

let particleCount = 100
// …
for i in 0..<particleCount {
    let position = particlePosition(timeInterval: timeInterval+(Double(i)/Double(particleCount)), canvasSize: size)
    context.draw(particleSymbol, at: position, anchor: .center)
}

Randomize

We can finally see more particles, but they all share the same path, so let me initiate each particle with random starting wave rotation and starting time offset:

struct ParticleCanvasView: View {
    let movementDuration: Double
    let particleCount: Int
    let startingParticleOffsets: [CGFloat]
    let startingParticleAlphas: [CGFloat]
    
    init(particleCount: Int = 200, movementDuration: Double = 3.0) {
        self.particleCount = particleCount
        self.movementDuration = movementDuration
        self.startingParticleOffsets = Array(0..<particleCount).map {_ in CGFloat.random(in: 0...1)}
        self.startingParticleAlphas = Array(0..<particleCount).map {_ in CGFloat.random(in: 0...CGFloat.pi*2)}
    }
    
    func particlePosition(index: Int, timeInterval: Double, canvasSize: CGSize) -> CGPoint {
        let startingRotation: CGFloat =  startingParticleAlphas[index]//CGFloat(index)/CGFloat(particleCount)*CGFloat.pi
        let startingTimeOffset = startingParticleOffsets[index]*movementDuration
        
        let time = (timeInterval+startingTimeOffset).truncatingRemainder(dividingBy: movementDuration)/movementDuration
        let rotations:CGFloat = 3
        let amplitude: CGFloat = 0.1+0.8*(1-time)
        
        let x = canvasSize.width/2 + cos(rotations*time*CGFloat.pi*2+startingRotation)*canvasSize.width/2*amplitude
        
        return CGPoint(x: x, y: (1-time)*canvasSize.height)
    }

Improving the effect appearance

In terms of particle motion, I consider this done, but we still need to fine-tune this effect’s appearance to get some juiciness.

The first improvement is changing the particle opacity during the opacity movement - this is quite simple by changing the context opacity before a draw call:

context.opacity = positionAndAlpha.1

Next, let’s utilize the blending capabilities of SwiftUI and set the particle appearance like this:

struct SingleParticleView: View {
    var body: some View {
        Circle().fill(Color.orange.opacity(0.4))
            .frame(width:35, height:35)
            .blendMode(.plusLighter)
            .blur(radius: 10)
    }
}

We make particles here as a nice big blurry spots, that blends together to form a fire volume. The blendMode(.plusLighter) combines overlapping orange dots, effectively brightening the result where the patrticles intersect.

At this point, I am still not very happy with the result and will need to introduce more tweaks. This is very typical in such a creative process that your initial idea is not exactly aligned with the implementation and you are required to iterate on it.

The thing that bothers me is that the particles are more dense at the top of the view, while I would prefer otherwise. To fix that, let me get rid of the even y-axis distribution and adjust the y-coordinate like this:

let y = (1-time*time)*canvasSize.height

Also, I would like to boost the particle vanishing effect so let me decrease the particle opacity with time.

The final effect I am happy with is:

struct ParticleCanvasView: View {
    let movementDuration: Double
    let particleCount: Int
    let startingParticleOffsets: [CGFloat]
    let startingParticleAlphas: [CGFloat]
    
    init(particleCount: Int = 200, movementDuration: Double = 3.0) {
        self.particleCount = particleCount
        self.movementDuration = movementDuration
        self.startingParticleOffsets = Array(0..<particleCount).map {_ in CGFloat.random(in: 0...1)}
        self.startingParticleAlphas = Array(0..<particleCount).map {_ in CGFloat.random(in: 0...CGFloat.pi*2)}
    }
    
    func particlePositionAndAlpha(index: Int, timeInterval: Double, canvasSize: CGSize) -> (CGPoint, CGFloat) {
        let startingRotation: CGFloat = startingParticleAlphas[index]
        let startingTimeOffset = startingParticleOffsets[index]*movementDuration
        
        let time = (timeInterval+startingTimeOffset).truncatingRemainder(dividingBy: movementDuration)/movementDuration
        let rotations:CGFloat = 1.5
        let amplitude: CGFloat = 0.1+0.8*(1-time)
        
        let x = canvasSize.width/2 + cos(rotations*time*CGFloat.pi*2 + startingRotation)*canvasSize.width/2*amplitude*0.8
        let y = (1-time*time)*canvasSize.height
        
        return (CGPoint(x: x, y: y), 1-time)
    }
    
    var body: some View {
        TimelineView(.animation) { context in
            let timeInterval = context.date.timeIntervalSinceReferenceDate;
            
            Canvas { context, size in
                let particleSymbol = context.resolveSymbol(id: 0)!
                
                for i in 0..<particleCount {
                    let positionAndAlpha = particlePositionAndAlpha(index: i, timeInterval: timeInterval, canvasSize: size)
                    context.opacity = positionAndAlpha.1
                    context.draw(particleSymbol, at: positionAndAlpha.0, anchor: .center)
                }
            } symbols: {
                SingleParticleView()
                    .tag(0)
            }
        }
    }
}

Your turn!

Now it is your time to get creative!

Things to try

  • change particle appearance
  • change particle movement paths
  • combine multiple particle types
  • react to user inputs
  • 💫 …

Here is a result of more experiments and adjustments:

Enjoy! And let me know, if you find this article helpful, and send me your animations on Twitter.