Skip to content

Instantly share code, notes, and snippets.

@rl-pavel
Last active March 5, 2024 07:21
Show Gist options
  • Save rl-pavel/65017a8738d8779a1bbc3f27b71d63b6 to your computer and use it in GitHub Desktop.
Save rl-pavel/65017a8738d8779a1bbc3f27b71d63b6 to your computer and use it in GitHub Desktop.
SwiftUI Particle Emitter
var body: some View {
ParticlesEmitter {
Particle(.circle(4))
.color(.red)
.velocity(25)
.scale(0.10)
.alphaSpeed(-0.3)
.spinRange(1)
.lifetime(5)
.birthRate(2)
.emissionRange(.pi / 3)
.emissionLongitude(-.pi / 2)
}
.emitterSize(.init(width: 100, height: 100))
.emitterPosition(.init(x: 50, y: 50))
.emitterShape(.circle)
.emitterMode(.surface)
.frame(width: 100, height: 100)
}
class Particle: CAEmitterCell {
/// Content of the particle - image, circle or a string/emoji.
enum Content {
case image(UIImage)
case circle(CGFloat)
case string(String)
}
// MARK: - Inits
init(_ content: Content) {
super.init()
self.contents = content.image.cgImage
self.minificationFilter = CALayerContentsFilter.trilinear.rawValue
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
// MARK: - Functions
func copyEmitter() -> Particle {
// swiftlint:disable force_cast
return super.copy() as! Particle
}
/// A rectangle (in the unit coordinate space) that specifies the portion of contents that the receiver should draw.
///
/// By default, this property is set to the unit rectangle (0.0, 0.0, 1.0, 1.0), which results in all of the layer’s
/// contents being drawn. If pixels outside the unit rectangle are requested, the edge pixels of the contents image
/// are extended outwards. If you assign an empty rectangle to this property, the results are undefined.
@inlinable func contentsRect(_ rect: CGRect) -> Self {
self.contentsRect = rect
return self
}
/// The number of emitted objects created every second. Animatable.
///
/// The default value of this property is 0.0.
@inlinable func birthRate(_ birthRate: Float) -> Self {
self.birthRate = birthRate
return self
}
/// The lifetime of the cell, in seconds. Animatable.
///
/// The lifetime of the cell will vary by a random amount with the range specified by lifetimeRange.
/// The default value of this property is 0.0.
@inlinable func lifetime(_ lifetime: Float) -> Self {
self.lifetime = lifetime
return self
}
@inlinable func contentsScale(_ contentsScale: CGFloat) -> Self {
self.contentsScale = contentsScale
return self
}
/// Specifies the scale factor applied to the cell. Animatable.
///
/// The scale of the cell will vary by a random amount within the range specified by scaleRange.
/// The scaleSpeed property determines the rate of change. The default value of this property is 1.0.
@inlinable func scale(_ scale: CGFloat) -> Self {
self.scale = scale
return self
}
/// Specifies the range over which the scale value can vary. Animatable.
///
/// The range specifies the mean amount that the scale value can vary for the cell over its lifetime.
/// The default value of this property is 0.0.
@inlinable func scaleRange(_ scaleRange: CGFloat) -> Self {
self.scaleRange = scaleRange
return self
}
/// The speed at which the scale changes over the lifetime of the cell. Animatable.
///
/// The speed change is defined as the rate of change per second. The default value of this property is 0.0.
@inlinable func scaleSpeed(_ scaleSpeed: CGFloat) -> Self {
self.scaleSpeed = scaleSpeed
return self
}
/// The initial velocity of the cell. Animatable.
///
/// The velocity of the cell will vary by a random amount within the range specified by velocityRange.
@inlinable func velocity(_ velocity: CGFloat) -> Self {
self.velocity = velocity
return self
}
/// The range specifies the mean amount the initial velocity value change. The default value of this property is 0.0.
///
/// The range specifies the mean amount the initial velocity value change.
/// The default value of this property is 0.0.
@inlinable func velocityRange(_ velocityRange: CGFloat) -> Self {
self.velocityRange = velocityRange
return self
}
/// The longitudinal orientation of the emission angle. Animatable.
///
/// The emission longitude is the orientation of the emission angle in the xy-plane.
/// It is also often referred to as the azimuth. The default value of this property is 0.0.
@inlinable func emissionLongitude(_ emissionLongitude: CGFloat) -> Self {
self.emissionLongitude = emissionLongitude
return self
}
/// The latitudinal orientation of the emission angle. Animatable.
///
/// The emission latitude is the orientation of the emission angle from the z-axis. It is also
/// referred to as the colatitude. The default value of this property is 0.0.
@inlinable func emissionLatitude(_ emissionLatitude: CGFloat) -> Self {
self.emissionLatitude = emissionLatitude
return self
}
/// The angle, in radians, defining a cone around the emission angle. Animatable.
///
/// Cells are uniformly distributed across this cone. The default value of this property is 0.
@inlinable func emissionRange(_ emissionRange: CGFloat) -> Self {
self.emissionRange = emissionRange
return self
}
/// The rotational velocity, measured in radians per second, to apply to the cell. Animatable.
///
/// The spin of the cell will vary by a random amount with the range specified by spinRange.
/// The default value of this property is 0.0.
@inlinable func spin(_ spin: CGFloat) -> Self {
self.spin = spin
return self
}
/// The amount by which the spin of the cell can vary over its lifetime. Animatable.
///
/// The range specifies the mean amount the spin value can vary over the cell's lifetime.
/// The default value of this property is 0.0.
@inlinable func spinRange(_ spinRange: CGFloat) -> Self {
self.spinRange = spinRange
return self
}
/// The specified color of the cell will vary by a random amount within the redRange, greenRange, blueRange and alpha
/// over the lifetime of the cell. The redSpeed, greenSpeed, blueSpeed, and alphaSpeed determine the rate of change.
///
/// The default value of this property is a color object set to opaque white.
@inlinable func color(_ color: UIColor) -> Self {
self.color = color.cgColor
return self
}
/// The x component of an acceleration vector applied to cell.
///
/// The default value of this property is 0.0.
@inlinable func xAcceleration(_ xAcceleration: CGFloat) -> Self {
self.xAcceleration = xAcceleration
return self
}
/// The y component of an acceleration vector applied to cell.
///
/// The default value of this property is 0.0.
@inlinable func yAcceleration(_ yAcceleration: CGFloat) -> Self {
self.yAcceleration = yAcceleration
return self
}
/// The z component of an acceleration vector applied to cell.
///
/// The default value of this property is 0.0.
@inlinable func zAcceleration(_ zAcceleration: CGFloat) -> Self {
self.zAcceleration = zAcceleration
return self
}
///The speed, in seconds, at which the alpha component changes over the lifetime of the cell. Animatable.
///
/// The speed change is defined as the rate of change per second.
/// The default value of this property is 0.
@inlinable func alphaSpeed(_ alphaSpeed: Float) -> Self {
self.alphaSpeed = alphaSpeed
return self
}
/// The amount by which the alpha component of the cell can vary. Animatable.
///
/// The range specifies the mean amount by which the alpha component of the color property can vary for the cell.
/// The default value of this property is 0.0.
@inlinable func alphaRange(_ alphaRange: Float) -> Self {
self.alphaRange = alphaRange
return self
}
/// An optional array containing the sub-cells of this cell.
///
/// When specified, each particle emitted by the cell acts as an emitter for each of the cell's sub-cells.
/// The emission point is the current particle position and the emission angle is relative to
/// the current direction of the particle. The default value of this property is nil.
@inlinable func subCells(@EmitterCellBuilder _ content: () -> [CAEmitterCell]) -> Self {
self.emitterCells = content()
return self
}
}
private extension Particle.Content {
var image: UIImage {
switch self {
case let .image(image):
return image
case let .string(string):
return string.image()
case let .circle(radius):
let size = CGSize(width: radius * 2, height: radius * 2)
return UIGraphicsImageRenderer(size: size).image { context in
context.cgContext.setFillColor(UIColor.white.cgColor)
context.cgContext.addPath(CGPath(ellipseIn: CGRect(origin: .zero, size: size), transform: nil))
context.cgContext.fillPath()
}
}
}
}
private extension String {
func image(with font: UIFont = UIFont.systemFont(ofSize: 16.0)) -> UIImage {
let string = NSString(string: "\(self)")
let attributes: [NSAttributedString.Key: Any] = [.font: font]
let size = string.size(withAttributes: attributes)
return UIGraphicsImageRenderer(size: size).image { _ in
string.draw(at: .zero, withAttributes: attributes)
}
}
}
import SwiftUI
@_functionBuilder
struct EmitterCellBuilder {
static func buildBlock(_ cells: CAEmitterCell...) -> [CAEmitterCell] {
Array(cells)
}
}
final class ParticleEmitterLayerView: UIView {
override static var layerClass: AnyClass { CAEmitterLayer.self }
// swiftlint:disable:next force_cast
var emitterLayer: CAEmitterLayer { layer as! CAEmitterLayer }
}
struct ParticlesEmitter: UIViewRepresentable {
var size: CGSize = .init(width: 1, height: 1)
var position: CGPoint = .zero
var shape: CAEmitterLayerEmitterShape = .line
var mode: CAEmitterLayerEmitterMode = .volume
var depth: CGFloat = 0
var zPosition: CGFloat = 0
var cells: [CAEmitterCell] = []
func makeUIView(context: Context) -> ParticleEmitterLayerView {
let particleEmitterView = ParticleEmitterLayerView()
configure(particleEmitterView)
return particleEmitterView
}
func updateUIView(_ emitterView: ParticleEmitterLayerView, context: UIViewRepresentableContext<ParticlesEmitter>) {
configure(emitterView)
}
private func configure(_ emitterView: ParticleEmitterLayerView) {
emitterView.emitterLayer.emitterSize = size
emitterView.emitterLayer.emitterPosition = position
emitterView.emitterLayer.emitterShape = shape
emitterView.emitterLayer.emitterMode = mode
emitterView.emitterLayer.emitterZPosition = zPosition
emitterView.emitterLayer.emitterCells = cells
emitterView.emitterLayer.emitterDepth = depth
}
}
extension ParticlesEmitter {
init(@EmitterCellBuilder _ content: () -> [CAEmitterCell]) {
self.init(cells: content())
}
init(@EmitterCellBuilder _ content: () -> CAEmitterCell) {
self.init(cells: [content()])
}
func emitterSize(_ size: CGSize) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
func emitterPosition(_ position: CGPoint) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
func emitterShape(_ shape: CAEmitterLayerEmitterShape) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
func emitterMode(_ mode: CAEmitterLayerEmitterMode) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
func emitterZPosition(_ zPosition: CGFloat) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
func emitterDepth(_ depth: CGFloat) -> Self {
ParticlesEmitter(
size: size,
position: position,
shape: shape,
mode: mode,
depth: depth,
zPosition: zPosition,
cells: cells
)
}
}
@MatyasKriz
Copy link

MatyasKriz commented Mar 5, 2024

Kudos, this works extremely well. 👏 Thank you for taking the time to build this.

I've added just one thing into the EmitterCellBuilder allowing me to add particles using the map operation, in my case multiple colors of confetti.

@resultBuilder
struct EmitterCellBuilder {
    ...

    static func buildBlock(_ components: [CAEmitterCell]...) -> [CAEmitterCell] {
        components.flatMap { $0 }
    }
}

which allows for this:

GeometryReader { geometry in
    ParticlesEmitter {
        Self.confettiColors.map { color in
            Particle(.confetti)
                .color(color)
                .velocity(100)
                .scale(0.6)
                .scaleRange(0.5)
                .alphaSpeed(-0.2)
                .alphaRange(0.5)
                .scaleRange(0.6)
                .spinRange(3)
                .lifetime(6)
                .birthRate(10)
                .emissionRange(.pi / 4)
                .emissionLongitude(.pi)
        }
    }
    .emitterSize(.init(width: geometry.size.width, height: 1))
    .emitterPosition(.init(x: geometry.size.width / 2, y: 0))
    .emitterShape(.line)
    .emitterMode(.surface)
    .frame(width: geometry.size.width, height: geometry.size.height)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment