Last active
March 5, 2024 07:21
-
-
Save rl-pavel/65017a8738d8779a1bbc3f27b71d63b6 to your computer and use it in GitHub Desktop.
SwiftUI Particle Emitter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 themap
operation, in my case multiple colors of confetti.which allows for this: