swiftparticle-systemcaemitterlayercaemittercell

Aligning four CAEmitterLayer() on each side of view, pointing inward


I'm working on adding 4 CAEmitterLayers to a subview. Each CAEmitterLayer is a line, and will be positioned on each side. Rotation will be controlled via CGAffineTransform

My problem:

I can't get the .Left and .Right emitterPosition to properly line up with the left and right side of the view

Here's my progress:

override func awakeFromNib() {
    super.awakeFromNib()
    self.layer.cornerRadius = GlobalConstants.smallCornerRadius
    createParticles(direction: .Up)
    createParticles(direction: .Down)
    createParticles(direction: .Left)
    createParticles(direction: .Right)
}

enum EmitTo {
    case Up
    case Down
    case Left
    case Right
}

func createParticles(direction: EmitTo) {
    self.layoutSubviews()
    let particleEmitter = CAEmitterLayer()
    particleEmitter.position = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
    particleEmitter.bounds = CGRect(x: 0, y: 0, width: self.bounds.size.width, height: self.bounds.size.height)
    
    
    if direction == .Up {
        particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: 0))
    } else if (direction == .Down) {
        particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: .pi))
    } else if (direction == .Left) {
        particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: .pi / 2))
    } else if (direction == .Right) {
        particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: -.pi / 2))
    }
    
    if direction == .Up || direction == .Down {
        particleEmitter.emitterPosition = CGPoint(x: self.frame.width / 2, y: 0)
    }
    if direction == .Left {
        particleEmitter.emitterPosition = CGPoint(x: self.frame.height / 2, y: self.frame.height)
    }
    if direction == .Right {
        particleEmitter.emitterPosition = CGPoint(x: self.frame.height / 2, y: -50)
    }
    
    
    particleEmitter.emitterSize = self.bounds.size
    particleEmitter.emitterShape = CAEmitterLayerEmitterShape.line
    particleEmitter.zPosition = 1
    
    let triangle = makeEmitterCell(direction: direction)
    particleEmitter.emitterCells = [triangle]

    self.layer.addSublayer(particleEmitter)
}

func makeEmitterCell(direction: EmitTo) -> CAEmitterCell {
    let cell = CAEmitterCell()
    cell.birthRate = 25
    cell.lifetime = 3
    cell.lifetimeRange = 0
    cell.velocity = 10
    cell.velocityRange = 5
    cell.emissionLongitude = .pi
    
    cell.spin = 2
    cell.spinRange = 3
    cell.scale = 0.4
    cell.scaleRange = 0.6
    cell.scaleSpeed = -0.10

    cell.contents = UIImage(named: "greenTriangle")?.cgImage
    return cell
}

What's happening:

gif animation of particle system

My question

How can I get the .Left and .Right CAEmitterLayer to properly align with the left side and right side of the gray view?

Green Triangle Image:

Green Triangle Image


Solution

  • Your approach to try to use CGAffineTransform is promising because a line emitter is a horizontal line that has only a width dimension. By rotating it, you can have it emit from the left or right.

    However you need to use emitter bounds with the view height as width and the view width as height.

    particleEmitter.bounds = CGRect(x: 0, y: 0, width: bounds.size.height, height: bounds.size.width)
    

    Accordingly, one sets the width of the emitterSize to the height of the view:

    particleEmitter.emitterSize = CGSize(width: bounds.size.height, height: 0)
    

    The emitter position must be adjusted in the same way.

    Why is this so? Take a look at the following illustration with an emission from the top:

    rotation illustration

    If you rotate the layer by 90 degrees you would only cover a small area in the middle. Also the emissions would not be visible, because they would be to much outside to the left.

    The difference between having the emitter on the left or the right is just a call to setAffineTransform with either -.pi / 2 or .pi / 2.

    The emitters from top and bottom of course should get the normal bounds, they only need to be rotated as you do in your question.

    Hints

    In your createParticles method you call self.layoutSubviews(). That should be avoided.

    Apple docs explicitly state:

    You should not call this method directly.

    see https://developer.apple.com/documentation/uikit/uiview/1622482-layoutsubviews.

    Instead you could override layoutSubviews and call your createParticles methods from there. Make sure to remove any previously created emitter layers by removing them.

    One small thing: Swift switch cases start with a lowercase letter by convention.

    Code

    A complete example to help you get started might then look something like this:

    import UIKit
    
    class EmitterView: UIView {
        
        private var emitters = [CAEmitterLayer]()
        
        enum EmitTo {
            case up
            case down
            case left
            case right
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            emitters.forEach { $0.removeFromSuperlayer() }
            emitters.removeAll()
            createParticles(direction: .up)
            createParticles(direction: .down)
            createParticles(direction: .left)
            createParticles(direction: .right)
        }
        
        func createParticles(direction: EmitTo) {
            let particleEmitter = CAEmitterLayer()
            emitters.append(particleEmitter)
            particleEmitter.position = CGPoint(x: bounds.midX, y: bounds.midY)
            particleEmitter.emitterShape = .line
    
            switch direction {
            case .up, .down:
                particleEmitter.bounds = self.bounds
                particleEmitter.emitterPosition = CGPoint(x: bounds.size.width / 2, y: 0)
                particleEmitter.emitterSize = CGSize(width: bounds.size.width, height: 0)
                if direction == .up {
                    particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: -.pi))
                }
            case .left, .right:
                particleEmitter.bounds = CGRect(x: 0, y: 0, width: bounds.size.height, height: bounds.size.width)
                particleEmitter.emitterPosition = CGPoint(x: bounds.height / 2, y: 0)
                particleEmitter.emitterSize = CGSize(width: bounds.size.height, height: 0)
                let rotationAngle: CGFloat = direction == EmitTo.left ?  -.pi / 2 : .pi / 2
                particleEmitter.setAffineTransform(CGAffineTransform(rotationAngle: rotationAngle))
            }
            particleEmitter.emitterCells = [makeEmitterCell()]
            
            layer.addSublayer( particleEmitter)
        }
        
        func makeEmitterCell() -> CAEmitterCell {
            let cell = CAEmitterCell()
            cell.birthRate = 25
            cell.lifetime = 3
            cell.lifetimeRange = 0
            cell.velocity = 10
            cell.velocityRange = 5
            cell.emissionLongitude = .pi
            
            cell.spin = 2
            cell.spinRange = 3
            cell.scale = 0.4
            cell.scaleRange = 0.6
            cell.scaleSpeed = -0.10
            
            cell.contents = UIImage(named: "greenTriangle")?.cgImage
            return cell
        }
    }
    

    Test

    demo