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.
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.
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 = [] } } } }
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.
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 = [] } } } }
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

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

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

Particle Effects on Swift Talk: part 3 (#420), part 4 (#421), part 5 (#422), and part 6 (#423).
https://talk.objc.io/episodes/S01E423-particle-effects-part-6