Debugging CAAnimation that will not start under very strange circumstances.

I have a navigation controller with two VCs. One VC is pushed onto the NavController, the other is presented on top of the NavController. The presented VC has a relatively complex animation involving a CAEmitter -> Animate birth rate down -> Fade out -> Remove. The pushed VC has an 'inputAccessoryView' and can become first responder.

The expected behavior is open presented VC -> Emitter Emits pretty pictures -> emitter stops gracefully.

The animation works perfectly. UNLESS I open pushed VC -> Leave -> go to presented VC. In this case when I open the presented VC the emitter emits pretty pictures -> they never stop. (Please do not ask me how long it took to figure this much out 🤬😔)

The animation code in question is:

        let animation = CAKeyframeAnimation(keyPath: #keyPath(CAEmitterLayer.birthRate))
        animation.duration = 1
        animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
        animation.values = [1, 0 , 0]
        animation.keyTimes = [0, 0.5, 1]
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        emitter.beginTime = CACurrentMediaTime()

        let now = Date()

        CATransaction.begin()
        CATransaction.setCompletionBlock { [weak self] in
            print("fade beginning -- delta: \(Date().timeIntervalSince(now))")
            let transition = CATransition()
            transition.delegate = self
            transition.type = .fade
            transition.duration = 1
            transition.timingFunction = CAMediaTimingFunction(name: .easeOut)
            transition.setValue(emitter, forKey: kKey)
            transition.isRemovedOnCompletion = false
            emitter.add(transition, forKey: nil)
            emitter.opacity = 0
        }
        emitter.add(animation, forKey: nil)
        CATransaction.commit()

The delegate method is:

extension PresentedVC: CAAnimationDelegate {
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        if let emitter = anim.value(forKey: kKey) as? CALayer {
            emitter.removeAllAnimations()
            emitter.removeFromSuperlayer()
        } else {
        }
    }
}

Here is the pushed VC:

class PushedVC: UIViewController {
    override var canBecomeFirstResponder: Bool {
        return true
    }
    
    override var canResignFirstResponder: Bool {
        return true
    }


    override var inputAccessoryView: UIView? {
        return UIView()
    }
}

So to reiterate - If I push pushedVC onto the navController, pop it, present PresentedVC the emitters emit, but then the call to emitter.add(animation, forKey: nil) is essentially ignored. The emitter just keeps emitting.

Here are some sample happy print statements from the completion block:

fade beginning -- delta: 1.016232967376709
fade beginning -- delta: 1.0033869743347168
fade beginning -- delta: 1.0054619312286377
fade beginning -- delta: 1.0080779790878296
fade beginning -- delta: 1.0088880062103271
fade beginning -- delta: 0.9923020601272583
fade beginning -- delta: 0.99943196773529

Here are my findings:

  • The issue presents only when the pushed VC has an inputAccessoryView AND canBecomeFirstResponder is true
  • It does not matter if the inputAccessoryView is UIKit or custom, has size, is visible, or anything.
  • When I dismiss PresentedVC the animation is completed and the print statements show. Here are some unhappy print examples:
fade beginning -- delta: 5.003802061080933
fade beginning -- delta: 5.219511032104492
fade beginning -- delta: 5.73025906085968
fade beginning -- delta: 4.330522060394287
fade beginning -- delta: 4.786169052124023
  • CATransaction.flush() does not fix anything
  • Removing the entire CATransaction block and just calling emitter.add(animation, forKey: nil) similarly does nothing - the birth rate decrease animation does not happen

I am having trouble creating a simple demo project where the issue is reproducible (it is 100% reproducible in my code, the entirety of which I'm not going to link here) so I think getting a "solution" is unrealistic. What I would love is if anyone had any suggestions on where else to look? Any ways to debug CAAnimation? I think if I can solve the last bullet - emitter.add(animation, forKey: nil) called w/o a CATransaction - I can break this whole thing. Why would a CAAnimation added directly to the layer which is visible and doing stuff refuse to run?

Replies

Demo project is here: https://github.com/NickNeedsAName/caanimissuedemo

To see the issue in the demo project, DL -> run

  • Tap "Present View"
  • Observe Console logs - fade begin -- delta: ~1s, animation did stop
  • Tap anywhere to dismiss the presented view
  • Tap "Push View"
  • Tap "Back"
  • Tap "Present View"
  • Observe Console logs - NONE
  • Tap anywhere to dismiss the presented view
  • Observe Console logs - fade begin -- delta: <however long you waited to tap>