SwiftUI Field Guide

Particle Animations

Star Burst

A particle system is essentially a collection of particles that are active at any given point in time. Particles are emitted and typically have a lifetime.

In the example below, we define a struct for a single particle with a lifetime of 1.2 seconds. The particle stores its birth date and uses a keyframe timeline to compute its properties: position, rotation, opacity, and scale. Finally, it has a method to get the particle's values at a specific point in time.

Code
struct Particle {    let birth: Date    let lifetime: TimeInterval = 1.2    let timeline: KeyframeTimeline<Values>        struct Values {        var x = 0.0        var y = 0.0        var rotation = 0.0        var opacity = 0.0        var scale = 0.4    }        init(birth: Date) {        self.birth = birth        let direction = Double.random(            in: 0..<(2 * .pi)        )        let distance = Double.random(in: 35...90)        timeline = KeyframeTimeline(            initialValue: Values()        ) {            KeyframeTrack(\.x) {                CubicKeyframe(                    cos(direction) * distance,                    duration: 1                )            }            KeyframeTrack(\.y) {                CubicKeyframe(                    sin(direction) * distance,                    duration: 1                )            }            KeyframeTrack(\.rotation) {                LinearKeyframe(360, duration: 1)            }            KeyframeTrack(\.opacity) {                LinearKeyframe(1, duration: 0.1)                CubicKeyframe(0, duration: 0.9)            }            KeyframeTrack(\.scale) {                CubicKeyframe(1, duration: 0.2)                CubicKeyframe(0.3, duration: 0.8)            }        }    }        func value(at date: Date) -> Values {        let progress = min(1,                           max(0,                               date.timeIntervalSince(                                   birth                               ) / lifetime))        return timeline.value(time: progress)    }        func isExpired(at date: Date) -> Bool {        date.timeIntervalSince(birth) >= lifetime    }}

The StarBurst view contains a TimelineView and a Canvas. The canvas paints all the particles. With the animation schedule, the timeline view updates its content while the animation is running. Depending on the context, this is often 60 or 120 times per second.

This is very similar to using CADisplayLink in UIKit or AppKit, except that the API has a different shape. Inside the timeline view, we use onChange(of:)to remove all expired particles when the timeline's date changes.

Code
import SwiftUIstruct StarBurst: View {    @State private var particles: [Particle] = []    @Environment(        \.accessibilityReduceMotion    ) private var reduceMotion        var body: some View {        TimelineView(.animation(                         paused: particles.isEmpty                     )) { timeline in            Canvas { context, size in                let path = shape                context.translateBy(                    x: size.width / 2,                    y: size.height * 0.5                )                for particle in particles {                    let value = particle.value(                        at: timeline.date                    )                    var copy = context                    copy.opacity = value.opacity                    copy.translateBy(x: value.x,                                     y: value.y)                    copy.rotate(by: .degrees(                                    value.rotation                                ))                    copy.scaleBy(x: value.scale,                                 y: value.scale)                    copy.fill(path,                              with: .color(                                  .orange                              ))                }                context.scaleBy(x: 2, y: 2)                context.fill(path,                             with: .color(                                 .orange                             ))            }            .onChange(                of: timeline.date            ) { _, date in                if particles.contains(                    where: { $0.isExpired(at: date) }                ) {                    particles.removeAll(                        where: { $0.isExpired(at: date) }                    )                }            }        }        .contentShape(.rect)        .onTapGesture() {            emit()        }        .onChange(            of: reduceMotion        ) { _, reduced in            if reduced {                particles = []            }        }    }                }
Preview
Loading preview…

In general, TimelineView allows us to fully control our animation by computing everything ourselves. With the .animation schedule, we get frame updates and can do whatever we want in there. There are subtleties: for example, if we take too long to draw, frames might be dropped. But in general, this is one of the lowest-level ways to render an animation in SwiftUI.

Floating Hearts

We can use the exact same technique to do other effects. For example, here are floating hearts.

Code
import SwiftUIstruct FloatingHearts: View {    @State private var particles: [Particle] = []    @Environment(        \.accessibilityReduceMotion    ) private var reduceMotion        var body: some View {        TimelineView(.animation(                         paused: particles.isEmpty                     )) { timeline in            Canvas { context, size in                let path = shape                context.translateBy(                    x: size.width / 2,                    y: size.height * 0.72                )                for particle in particles {                    let value = particle.value(                        at: timeline.date                    )                    var copy = context                    copy.opacity = value.opacity                    copy.translateBy(x: value.x,                                     y: value.y)                    copy.rotate(by: .degrees(                                    value.rotation                                ))                    copy.scaleBy(x: value.scale,                                 y: value.scale)                    copy.fill(path,                              with: .color(.pink))                }                context.scaleBy(x: 2, y: 2)                context.fill(path,                             with: .color(.pink))            }            .onChange(                of: timeline.date            ) { _, date in                if particles.contains(                    where: { $0.isExpired(at: date) }                ) {                    particles.removeAll(                        where: { $0.isExpired(at: date) }                    )                }            }        }        .contentShape(.rect)        .onTapGesture() {            emit()        }        .onChange(            of: reduceMotion        ) { _, reduced in            if reduced {                particles = []            }        }    }                        }
Preview
Loading preview…

You can build a more generic particle system based on this approach, as we show in the Swift Talk episodes linked below. TimelineViewis useful beyond particle systems as well. Using a different schedule, it's even useful beyond animations.

Resources

Share Image

Apple's Add rich graphics to your SwiftUI app (WWDC21), with a particle effect using Canvas and TimelineView.

https://developer.apple.com/videos/play/wwdc2021/10021/
Share Image

Apple's Wind your way through advanced animations in SwiftUI (WWDC23), including a KeyframeTimeline example.

https://developer.apple.com/videos/play/wwdc2023/10157/