I’m implementing confetti effect with particles. I used for it CAEmitterLayer, and it works almost good, but sometimes particles are not emitted. I tried my best and debugged it (set colors to emitter layers) and I know that the layers are added. Here’s the full code:
import UIKit
final class ViewController: UIViewController {
// MARK: - UI
private lazy var actionButton: UIButton = { [weak self] in
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Start confetti", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .systemBlue
button.layer.cornerRadius = 16
button.addAction(UIAction { _ in
self?.startConfettiShow()
}, for: .touchUpInside)
return button
}()
private let confettiContainer: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .white
view.clipsToBounds = true
return view
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .gray
view.addSubview(confettiContainer)
view.addSubview(actionButton)
NSLayoutConstraint.activate([
confettiContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
confettiContainer.bottomAnchor.constraint(equalTo: actionButton.topAnchor, constant: -50),
confettiContainer.widthAnchor.constraint(equalToConstant: 300),
confettiContainer.heightAnchor.constraint(equalToConstant: 300),
actionButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
actionButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 200),
actionButton.widthAnchor.constraint(equalToConstant: 150),
actionButton.heightAnchor.constraint(equalToConstant: 40),
])
}
// MARK: - Confetti Logic
private func startConfettiShow() {
// Remove old emitters
confettiContainer.layer.sublayers?.removeAll(where: { $0 is CAEmitterLayer })
view.layoutIfNeeded()
let xOffset: CGFloat = 90
let emissionOffset: CGFloat = 0.1
let left = makeEmitter(xOffset: -xOffset, emissionLongitudeOffset: emissionOffset)
let right = makeEmitter(xOffset: xOffset, emissionLongitudeOffset: -emissionOffset)
confettiContainer.layer.addSublayer(left)
confettiContainer.layer.addSublayer(right)
debugEmitter(left, color: .red)
debugEmitter(right, color: .yellow)
DispatchQueue.main.async {
CATransaction.begin()
CATransaction.setDisableActions(true)
let now = CACurrentMediaTime()
left.beginTime = now
right.beginTime = now
left.birthRate = 1
right.birthRate = 1
CATransaction.commit()
}
// Stop emission
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
left.birthRate = 0
right.birthRate = 0
}
// Remove the layers
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
left.removeFromSuperlayer()
right.removeFromSuperlayer()
}
}
private func makeEmitter(xOffset: CGFloat,
emissionLongitudeOffset: CGFloat) -> CAEmitterLayer {
let emitter = CAEmitterLayer()
emitter.frame = confettiContainer.bounds
emitter.emitterShape = .point
emitter.emitterPosition = CGPoint(
x: confettiContainer.bounds.midX + xOffset,
y: confettiContainer.bounds.maxY - 10
)
emitter.birthRate = 0
let cells: [CAEmitterCell] = ViewController.colors.flatMap { color in
ViewController.shapes.map { shape in
let cell = CAEmitterCell()
cell.birthRate = 15
cell.lifetime = 3.5
cell.velocity = .random(in: 250...400)
cell.velocityRange = 80
cell.emissionLongitude = -.pi / 2 + emissionLongitudeOffset
cell.emissionRange = .pi / 8
cell.yAcceleration = 200
cell.spin = .random(in: -3...3)
cell.spinRange = 4
cell.scale = .random(in: 0.08...0.14)
cell.alphaSpeed = -0.4
cell.color = color.cgColor
cell.contents = shape
return cell
}
}
emitter.emitterCells = cells
return emitter
}
private func debugEmitter(_ emitter: CAEmitterLayer, color: UIColor) {
emitter.borderColor = color.cgColor
emitter.borderWidth = 1
emitter.backgroundColor = color.withAlphaComponent(0.15).cgColor
print("Emitter frame:", emitter.frame)
print("Emitter position:", emitter.emitterPosition)
}
}
// MARK: - Static resources
private extension ViewController {
static let colors: [UIColor] = [
.systemPink, .systemYellow, .systemBlue, .systemGreen, .systemPurple
]
static let shapes: [CGImage] = [
CGImage.cgImageCircle(diameter: 36),
CGImage.cgImageSquare(side: 32),
CGImage.cgImageTriangle(size: 36),
].compactMap { $0 }
}
// MARK: - Shape generators
extension CGImage {
static func cgImageCircle(diameter: CGFloat) -> CGImage? {
let size = CGSize(width: diameter, height: diameter)
return UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.white.setFill()
ctx.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size))
}.cgImage
}
static func cgImageSquare(side: CGFloat) -> CGImage? {
let size = CGSize(width: side, height: side)
return UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.white.setFill()
ctx.cgContext.fill(CGRect(origin: .zero, size: size))
}.cgImage
}
static func cgImageTriangle(size: CGFloat) -> CGImage? {
let s = CGSize(width: size, height: size)
return UIGraphicsImageRenderer(size: s).image { ctx in
UIColor.white.setFill()
let path = UIBezierPath()
path.move(to: CGPoint(x: s.width / 2, y: 0))
path.addLine(to: CGPoint(x: s.width, y: s.height))
path.addLine(to: CGPoint(x: 0, y: s.height))
path.close()
path.fill()
}.cgImage
}
}
I’m setting beginTime and birthRate after emitters being added to view’s layer, I even used CATransaction.begin() and CATransaction.commit() (ChatGPT advice), but it didn’t worked. With all these improvements it started working better, I see less of emitters not working, but still sometimes it doesn’t work, so the element is unstable.
Check the screenshot. There’re two emitters, but only one is emitting. Rarely they don’t emit both.
