CAEmitterLayer, eventually, has delay when adding EmitterCells - ios

A very bizarre issue we've been seeing (gifs below),
We have a presented View Controller that has a TeamBadgeView,
which is a button that emits emoji as CAEmitterCells
Tapping this button lets users spam a fire emoji on their screen
Dismissing the presented view controller, and re-present the view controller, and now there is a delay. The more times I present/dismiss the view controller, the CAEmitterCell becomes more and more unresponsive
Confirmed that this is not a leak issue, the view controller and button are being properly deallocated
I have tried moving the CAEmitterLayer and CAEmitterCell around, holding a reference in the button, and declaring locally, but similar issues
Perhaps most bizarre, if I do not press the button at all, and simply present/dismiss the viewcontroller many times, and then press the button, there is a delay. The only time there isn't a delay is pressing the button on the first time the View Controller is presented
I have confirmed that the button's action is being fired correct, everytime I spam the button. It's just that the emitter cell is not rendering for a few seconds. And some of the emitter cells just don't render at all
It's gotten to the mind-boggling point, does anybody have any ideas or leads on what this could be?
First presentation of ViewController:
After 5th presentation of ViewController (Pressing button at same rate):
ViewController code:
let teamBadgeView = TeamBadgeView.fromNib()
teamBadgeView.configure()
Button code:
class TeamBadgeView: UIView {
let emitter = CAEmitterLayer()
let fireSize = CGSize(width: 16, height: 18)
let fireScale: CGFloat = 0.8
func configure() {
emitter.seed = UInt32(CACurrentMediaTime())
emitter.emitterPosition = CGPoint(x: bounds.midX, y: 0)
emitter.emitterShape = CAEmitterLayerEmitterShape.line
emitter.emitterSize = fireSize
emitter.renderMode = CAEmitterLayerRenderMode.additive
layer.addSublayer(emitter)
}
#IBAction func tapAction(_ sender: Any) {
emitFire()
}
private func emitFire() {
let cell = CAEmitterCell()
let beginTime = CACurrentMediaTime()
cell.birthRate = 1
cell.beginTime = beginTime
cell.duration = 1
cell.lifetime = 1
cell.velocity = 250
cell.velocityRange = 50
cell.yAcceleration = 100
cell.alphaSpeed = -1.5
cell.scale = fireScale
cell.emissionRange = .pi/8
cell.contents = NSAttributedString(string: "🔥").toImage(size: fireSize)?.cgImage
emitter.emitterCells = [cell]
}
}

Instead of setting the emitterCells array every time:
emitter.emitterCells = [cell]
...append the new cell to it. Make sure to initialize it to an empty array if it's nil though, or else the append will not work:
if emitter.emitterCells == nil {
emitter.emitterCells = []
}
emitter.emitterCells?.append(cell)

Thanks to #TylerTheCompiler we were able to figure this out, and it was really lame.
One line change, instead of setting the emitterCells, we needed to append
emitter.emitterCells = [cell]
became
emitter.emitterCells?.append(cell)
Why we didn't notice this was because it appears there is a weird interaction with Hero transitions. Our ViewController is presented via a Hero Transition, and for some reason the first time it's presented, emitterCells = [cell] works as expected... but then for some reason, for each subsequent Hero Transition to the ViewController, the cells start emitting slower and slower until it's back to the expected slow state. Incredibly strange, perhaps a bug in Hero, but who knows

Related

Animate UIImageView appearing on screen line by line

I'd like to display a UIImageView with some animation and i'm thinking i'd like for it to appear on the screen pixel by pixel moving left to right, line by line. A bit like a printer would print an image.
I haven't got a clue where to start with this.
I was thinking maybe overlay the UIImageView with another view that can use animation to become transparent, but how can I make it happen?
Well one idea is, we have one view on top of your image, covering it entirely. Let's call this view V. Move that view down by 1 point, so a line of your image is exposed. Then have another view on top of your image, covering it entirely again. Let's call this view H. Then move that view right by 1 point. Now one "pixel" (or rather, a 1x1 point grid) of your image is exposed.
We'll animate H to the right. When it reaches the end, we'll put it back where it started, move V and H down by 1 point, and repeat the process.
Here's something to get you started.
extension UIView {
/**
- Parameter seconds: the time for one line to reveal
*/
func scanReveal(seconds: Double) {
let colour = self.superview?.backgroundColor ?? UIColor.black
let v = UIView(frame: self.bounds)
v.backgroundColor = colour
self.addSubview(v)
let h = UIView(frame: self.bounds)
h.backgroundColor = colour
self.addSubview(h)
v.frame.origin.y += 1
// Animate h to the end.
// When it reaches the end, bring it back, move v and h down by 1 and repeat.
func move() {
UIView.animate(withDuration: seconds, animations: {
h.frame.origin.x = h.bounds.height
}) { _ in
h.frame.origin.x = 0
h.frame.origin.y += 1
v.frame.origin.y += 1
if v.frame.origin.y > self.bounds.height {
return
}
move()
}
}
move()
}
}

Why SpriteKit's 'addChild' method result in chaos

I'm in a trouble of SpriteKit's 'addChild' method.
I tried to add a simple button to my scene , and the button is formed of one background image and one label.So I wrote code like this:
override func didMove(to view: SKView) {
let buttonBg = SKSpriteNode(imageNamed: "mainButton_green")
let buttonLabel = SKLabelNode(text: "Label")
self.addChild(buttonBg)
self.addChild(buttonLabel)
}
Then I ran the program on simulator and I found that result is strange.Sometimes it appeared right,like this:
correct appearence
But sometimes, 'buttonLabel' will be behind of 'buttonBg', just like this:
wrong appearence
Why?
ps. I had print self.children , and the result was '[buttonBg,buttonLabel]' in each situation , my skview.ignoresSiblingOrder was always false.
A couple of things you can do
let buttonBg = SKSpriteNode(imageNamed: "mainButton_green")
buttonBg.zPosition = 1
let buttonLabel = SKLabelNode(text: "Label")
buttonLabel.zPosition = 2
which manually sets the z layering of your objects
I also think that if you are creating a button object you should add the label as a child of the background
let buttonBg = SKSpriteNode(imageNamed: "mainButton_green")
buttonBg.zPosition = 1
let buttonLabel = SKLabelNode(text: "Label")
buttonLabel.zPosition = 1
self.addChild(buttonBg)
buttonBg.addChild(buttonLabel)
I always add a zPosition even if its the only child.
Your issue is not a real problem: when you create your elements, following your case they'll be rendered in the main thread following some rules like:
statements order
zPosition property
alpha property
etc..
So, something like a litlle label could be drawed before your button background causing this annoying aspect.
A good thing to do when you want to create a button should be:
let buttonBg = SKSpriteNode(imageNamed: "mainButton_green")
self.addChild(buttonBg)
let buttonLabel = SKLabelNode(text: "Label")
buttonBg.addChild(buttonLabel)
In other words, put your label as a child of your background, this should will guarantee to you a correct zPosition levels (without change zPosition properties), a correct label position (by default CGPoint.zero) always if you haven't changed (leave untouched) the anchorPoint of your elements.

Why are my UI elements not resetting correctly after being animated/scaled off screen?

So I'll give as much information about this project as I can right up front. Here is an image of a section of the storyboard that is relevant to the issue:
And here is the flow of the code:
1) A user plays the game. This scrambles up the emoji that are displayed and will eventually hide all of the emoji on the right side.
2) When someone wins the game, it calls
performSegue(withIdentifier: "ShowWinScreenSegue", sender: self)
Which will perform the segue the red arrow is pointing to. This segue is a modal segue, over current content, cross dissolve.
3) Stuff goes on here, and then I try to get back to the game screen so the user can play another game. Here is my current code for that
// self.delegate is the GameController that called the segue
// it's set somewhere else in the code so I can call these reset functions
GameController.gs = GameState()
guard let d = self.delegate else {
return
}
d.resetGameToMatchState()
dismiss(animated: true, completion: {
print("Modal dismiss completed")
GameController.gs = GameState()
self.delegate?.resetGameToMatchState()
})
So here's where the issue is. You can see that I have to call delegate?.resetGameToMatchState() twice for anything to actually happen. If I remove the top one, nothing happens when I call the second one and vice-versa. What makes this so annoying is that the user will see a weird jump where all the ui goes from the old state to the new state because it's updating so late and spastically.
What I've tried so far
So this whole issue has made me really confused on how the UI system works.
My first thought was that maybe the function is trying to update the UI in a thread that's executing too early for the UI thread. So I put the whole body of resetGameToMatchState in a DispatchQueue.main.async call. This didn't do anything.
Then I thought that it was working before because when the WinScreenSegue was being dismissed before (when it was a "show" segue) it was calling GameController's ViewDidAppear. I tried manually calling this function in the dismiss callback, but that didn't work either and feels really hacky.
And now I'm stuck :( Any help would be totally appreciated. Even if it's just a little info that can clear up how the UI system works.
Here is my resetGameToMatchState():
//reset all emoji labels
func resetGameToMatchState() {
DispatchQueue.main.async {
let tier = GameController.gs.tier
var i = 0
for emoji in self.currentEmojiLabels! {
emoji.frame = self.currentEmojiLabelInitFrames[i]
emoji.isHidden = false
emoji.transform = CGAffineTransform(scaleX: 1, y: 1);
i+=1
}
i=0
for emoji in self.goalEmojiLabels! {
emoji.frame = self.goalEmojiLabelInitFrames[i]
emoji.isHidden = false
emoji.transform = CGAffineTransform(scaleX: 1, y: 1);
i+=1
}
//match state
for i in 1...4 {
if GameController.gs.currentEmojis[i] == GameController.gs.goalEmojis[i] {
self.currentEmojiLabels?.findByTag(tag: i)?.isHidden = true
}
}
//reset highlight
let f = self.highlightBarInitFrame
let currentLabel = self.goalEmojiLabels?.findByTag(tag: tier)
let newSize = CGRect(x: f.origin.x, y: (currentLabel?.frame.origin.y)!, width: f.width, height: (currentLabel?.frame.height)! )
self.highlightBarImageView.frame = newSize
//update taps
self.updateTapUI()
//update goal and current emojis to show what the current goal/current selected emoji is
self.updateGoalEmojiLabels()
self.updateCurrentEmojiLabels()
}
}
UPDATE
So I just found this out. The only thing that isn't working when I try to reset the UI is resetting the right side emoji to their original positions. What I do is at the start of the app (in viewDidLoad) I run this:
for emoji in currentEmojiLabels! {
currentEmojiLabelInitFrames.append(emoji.frame)
}
This saves their original positions to be used later. I do this because I animate them to the side of the screen before hiding them.
Now when I want to reset their positions, I do this:
var i = 0
for emoji in self.currentEmojiLabels! {
emoji.frame = self.currentEmojiLabelInitFrames[i]
emoji.isHidden = false
emoji.transform = CGAffineTransform(scaleX: 1, y: 1);
i+=1
}
this should set them all to their original frame and scale, but it doesn't set the position correctly. It DOES reset the scale though. What's weird is that I can see a tiny bit of one of the emoji off to the left of the screen and when they animate, they animate from far off on the left. I'm trying to think of why the frames are so off...
UPDATE 2
So I tried changing the frame reset code to this:
emoji.frame = CGRect(x: 25, y: 25, width: 25, height: 25)
Which I thought should reset them correctly to the top left, but it STILL shoves them off to the left. This should prove that the currentEmojiLabelInitFrames are not the issue and that it has something to do with when I'm setting them. Maybe the constraints are getting reset or messed up?
Your first screen, GameController, should receive a viewWillAppear callback from UIKit when the modal WinScreenController is being dismissed.
So its resetGameToMatchState function could simply set a property to true, then your existing resetGameToMatchState could move into viewWillAppear, checking first if the property is being set.
var resetNeeded: Bool = false
func resetGameToMatchState() {
resetNeeded = true
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// reset code here
}
TLDR; Reset an element's scale BEFORE resetting it's frame or else it will scale/position incorrectly
Finally figured this out. Here's a bit more background. When an emoji is animated off the screen, this is called:
UIView.animate(withDuration: 1.5, animations: {
let newFrame = self.profileButton.frame
prevLabel?.frame = newFrame
prevLabel?.transform = CGAffineTransform(scaleX: 0.1, y: 0.1);
}) { (finished) in
prevLabel?.isHidden = true
}
So this sets the frame to the top left of the screen and then scales it down. What I didn't realize is that when I want to reset the element, I NEED to set the scale back to normal before setting the frame. Here is the new reset code:
for emoji in self.currentEmojiLabels! {
emoji.transform = CGAffineTransform(scaleX: 1, y: 1) //this needs to be first
emoji.frame = self.currentEmojiLabelInitFrames[i] //this needs to be after the scale
emoji.isHidden = false
i+=1
}

Adding image to particle emitter and stopping after a duration in swift/ios

I've been trying to learn and understand the emitter functions of CAEmitter, but I'm currently a little bit stuck. I want to add an image for the emitter and make it stop after a duration.
I've got a view that I'm using to emit some particles, and I want them to only appear emit when the view appears for around 10 seconds, then stop. I also am unsure how to attach a UI image with a png, instead of using CGrect.
Thanks for any help and advice!
import UIKit
class ParticleView: UIView {
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func drawRect(rect: CGRect) {
UIGraphicsBeginImageContextWithOptions(CGSizeMake(15,8), false, 1)
let con = UIGraphicsGetCurrentContext()
CGContextAddRect(con, CGRectMake(0, 0, 15, 8))
CGContextSetFillColorWithColor(con, UIColor.whiteColor().CGColor)
CGContextFillPath(con)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// make a cell with that image
var cell = CAEmitterCell()
cell.birthRate = 10
cell.color = UIColor(red:0.5, green:0.5, blue:0.5, alpha:1.0).CGColor
cell.redRange = 1
cell.blueRange = 1
cell.greenRange = 1
cell.lifetime = 5
cell.alphaSpeed = -1/cell.lifetime
cell.velocity = -100
cell.spinRange = 10.0
cell.scale = 1.0;
cell.scaleRange = 0.2;
cell.emissionRange = CGFloat(M_PI)/5.0
cell.contents = im.CGImage
var emit = CAEmitterLayer()
emit.emitterSize = CGSize(width: 100, height: 0)
emit.emitterPosition = CGPointMake(30,100)
emit.emitterShape = kCAEmitterLayerLine
emit.emitterMode = kCAEmitterLayerLine
emit.emitterCells = [cell]
self.layer.addSublayer(emit)
}
}
I have found a nice workaround to stop CAEmitter:
Create 2 identical view controllers with the same layout
Implement a Start and Stop button on both (to begin and end the CAEmitter)
Connect the Stop button of each view controller to each other with a “Show Detail (e.g. Replace) Segue and deselect "Animates"
When you hit “Stop” button it will make a seamless transition to the identical VC without emitter particles!! But what is really happening is that you are just switching to a replica view controller. This is not an elegant solution but it is the only reliable way that I have found to stop a CAEmitter (segue to a different VC) all of the other solutions are "buggy"
visual of how the VCs are set up

Staggered animations with CAKeyframeAnimation?

I want to animate 3 different images at specific point in time such that it behaves this way.
1) 1st image moves from (Xx, Yx) to (Xz,Yz)
2) Wait 10 seconds
3) 2nd image appears in place at Xa,Yb
4) Wait half as long as in step 2
5) Fade out 2nd image
6) 3rd image appears at the same place as 2nd image
If each of these image's animations are on their own CALayers, can I use CAKeyframeAnimation with multiple layers? If not, what's another way to go about doing staggered animations?
I'm trying to animate a playing card move from offscreen to a particular spot and then few other tricks to appear on screen several seconds later.
Edited
When I wrote this, I thought you could not use a CAAnimationGroup to animate multiple layers. Matt just posted an answer demonstrating that you can do that. I hereby eat my words.
I've taking the code in Matt's answer and adapted it to a project which I've uploaded to Github (link.)
The effect Matt's animation creates is of a pair of feet walking up the screen. I found some open source feet and installed them in the project, and made some changes, but the basic approach is Matt's. Props to him.
Here is what the effect looks like:
(The statement below is incorrect)
No, you can't use a keyframe animation to animate multiple layers. A given CAAnimation can only act on a single layer. This includes group layers, by the way.
If all you're doing is things like moving images on a straight line, fading out, and fading in, why don't you use UIView animation? Take a look at the methods who's names start with animateWithDuration:animations: Those will let you create multiple animations at the same time, and the completion block can then trigger additional animations.
If you need to use layer animation for some reason, you can use the beginTime property (which CAAnimation objects have because they conform to the CAMediaTiming protocol.) For CAAnimations that are not part of an animation group, you use
animation.beginTime = CACurrentMediaTime() + delay;
Where delay is a double which expresses the delay in seconds.
If the delay is 0, the animation would begin.
A third option would be to set your view controller up as the delegate of the animation and use the animationDidStop:finished: method to chain your animations. This ends up being the messiest approach to implement, in my opinion.
The claim that a single animation group cannot animate properties of different layers is not true. It can. The technique is to attach the animation group to the superlayer and refer to the properties of the sublayers in the individual animations' key paths.
Here is a complete example just for demonstration purposes. When launched, this project displays two "footprints" that proceed to step in alternation, walking off the top of the screen.
class ViewController: UIViewController, CAAnimationDelegate {
let leftfoot = CALayer()
let rightfoot = CALayer()
override func viewDidLoad() {
super.viewDidLoad()
self.leftfoot.name = "left"
self.leftfoot.contents = UIImage(named:"leftfoot")!.cgImage
self.leftfoot.frame = CGRect(x: 100, y: 300, width: 50, height: 80)
self.view.layer.addSublayer(self.leftfoot)
self.rightfoot.name = "right"
self.rightfoot.contents = UIImage(named:"rightfoot")!.cgImage
self.rightfoot.frame = CGRect(x: 170, y: 300, width: 50, height: 80)
self.view.layer.addSublayer(self.rightfoot)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.start()
}
}
func start() {
let firstLeftStep = CABasicAnimation(keyPath: "sublayers.left.position.y")
firstLeftStep.byValue = -80
firstLeftStep.duration = 1
firstLeftStep.fillMode = .forwards
func rightStepAfter(_ t: Double) -> CABasicAnimation {
let rightStep = CABasicAnimation(keyPath: "sublayers.right.position.y")
rightStep.byValue = -160
rightStep.beginTime = t
rightStep.duration = 2
rightStep.fillMode = .forwards
return rightStep
}
func leftStepAfter(_ t: Double) -> CABasicAnimation {
let leftStep = CABasicAnimation(keyPath: "sublayers.left.position.y")
leftStep.byValue = -160
leftStep.beginTime = t
leftStep.duration = 2
leftStep.fillMode = .forwards
return leftStep
}
let group = CAAnimationGroup()
group.duration = 11
group.animations = [firstLeftStep]
for i in stride(from: 1, through: 9, by: 4) {
group.animations?.append(rightStepAfter(Double(i)))
group.animations?.append(leftStepAfter(Double(i+2)))
}
group.delegate = self
self.view.layer.add(group, forKey: nil)
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
print("done")
self.rightfoot.removeFromSuperlayer()
self.leftfoot.removeFromSuperlayer()
}
}
Having said all that, I should add that if you are animating a core property like the position of something, it might be simpler to make it a view and use a UIView keyframe animation to coordinate animations on different views. Still, the point is that to say that this cannot be done with CAAnimationGroup is just wrong.

Resources