iosswiftrecursioncabasicanimationuiviewpropertyanimator

Incorrect UIViewPropertyAnimator behaviour when called recursively and app is in background


I have created a very simple class (SpinningCircleView) of type UIView that performs a spinning circle animation forever. I want to call this class from my ViewController to display the spinning circle animation on the screen. While the animation works great, I am observing incorrect behavior when the app is put in the background. Here is what the spinning circle animation looks like:

Spinning circle animation captured on Simulator

To create the spinning circle, I am using two separate UIViewPropertyAnimator, one to rotate the circle by 180 degrees (i.e. Pi) and the other to complete to 360 (i.e. 0 degrees). In the completion block of the second animator, I am then recursively calling the startSpinningCircleAnimation() function. I created a counter to track the number of times the startSpinningCircleAnimation() is called (i.e. the number of times the circle has rotated). While the app is active (in the foreground), the counter increments as expected and I can see the output in my Xcode terminal window:

Starting animation
1: + START Spinning Circle Animation
2: + START Spinning Circle Animation -> recursive call
3: + START Spinning Circle Animation -> recursive call
4: + START Spinning Circle Animation -> recursive call

The problem or incorrect behavior happens is when I put the app into the background...all of a sudden I am seeing several hundred "+ START Spinning Circle Animation -> recursive call" in my terminal. When the app comes back to the foreground, the counter and terminal output resume normal increments of the counter.

Why are several hundred calls being made to the startSpinningCircleAnimation() function when the app is put in the background? How can I correctly pause the animation and resume the animation as the app is moved between background and foreground? I have scoured through various posts but I can't figure out the solution.

Please help!!

Here is my SpinningCircleView class:

import UIKit

class SpinningCircleView: UIView
{
    
    private lazy var spinningCircle = CAShapeLayer()
    private lazy var animator1 = UIViewPropertyAnimator(duration: 1, curve: .linear, animations: nil)
    private lazy var animator2 = UIViewPropertyAnimator(duration: 1, curve: .linear, animations: nil)
    public var counter = 0
    
    override init (frame: CGRect)
    {
        super.init(frame: frame)
        
        configure()
    }
    
    required init?(coder: NSCoder)
    {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func configure()
    {
        frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        let rect = self.bounds
        let circularPath = UIBezierPath(ovalIn: rect)
        spinningCircle.path = circularPath.cgPath
        spinningCircle.fillColor = UIColor.clear.cgColor
        spinningCircle.strokeColor = UIColor.systemRed.cgColor
        spinningCircle.lineWidth = 10
        spinningCircle.strokeEnd = 0.25
        spinningCircle.lineCap = .round
        self.layer.addSublayer(spinningCircle)
    }
    
    func startSpinningCircleAnimation()
    {
        counter += 1
        
        let criteria1 = animator1.state == .active && !animator1.isRunning
        let criteria2 = animator2.state == .active && !animator2.isRunning
        let criteria3 = animator1.state == .inactive && animator2.state == .inactive
        let criteria4 = (animator1.state == .inactive && animator2.state.rawValue == 5) || (animator2.state == .inactive && animator1.state.rawValue == 5)
        
        if (criteria1)
        {
            // Since animator1 is Paused, we will resume the animation
            print("\(self.counter): ~ RESUME Spinning Circle Animation")
            animator1.startAnimation()
        } else if (criteria2)
        {
            // Since animator2 is Paused, we will resume the animation
            print("\(self.counter): ~ RESUME Spinning Circle Animation")
            animator2.startAnimation()
        } else if (criteria3 || criteria4)
        {
            
            if (criteria3)
            {
                print("\(self.counter): + START Spinning Circle Animation")
            } else if (criteria4)
            {
                print("\(self.counter): + START Spinning Circle Animation -> recursive call")
            }
            
            animator1.addAnimations
            {
                self.transform = CGAffineTransform(rotationAngle: .pi)
            }
            
            animator1.addCompletion
            {   _ in
                
                self.animator2.addAnimations
                {
                    self.transform = CGAffineTransform(rotationAngle: 0)
                }
                
                self.animator2.addCompletion
                { _ in
                    // Recursively call this start spinning
                    self.startSpinningCircleAnimation()
                }
                
                self.animator2.startAnimation()
            }
            
            animator1.startAnimation()
        } else
        {
            print("\(self.counter): >>>>>>>>> HERE <<<<<<<<<<<  \(self.animator1.state) \(self.animator1.isRunning) \(self.animator2.state) \(self.animator2.isRunning)")
        }
        
    }
    
    
    func stopSpinningCircleAnimation()
    {
        print("\(self.counter): - STOP Spinning Circle Animation Begin: \(self.animator1.state) \(self.animator1.isRunning) \(self.animator2.state) \(self.animator2.isRunning)")
        
        if (self.animator1.isRunning)
        {
            self.animator1.pauseAnimation()
        } else if (self.animator2.isRunning)
        {
            self.animator2.pauseAnimation()
        }
        
        print("\(self.counter): - STOP Spinning Circle Animation End: \(self.animator1.state) \(self.animator1.isRunning) \(self.animator2.state) \(self.animator2.isRunning)")
    }

}

Here is my ViewController which sets up an instance of the SpinningCircleView and starts animating:

class ViewController: UIViewController
{

    private lazy var spinningCircleView = SpinningCircleView()
    
    override func viewDidLoad()
    {
        super.viewDidLoad()
        
        // Setup the spinning circle and display the animation to the screen
        spinningCircleView.frame = CGRect(x: view.center.x - 50, y: 100, width: 100, height: 100)
        spinningCircleView.tag = 100
        view.addSubview(spinningCircleView)
    }
    
    override func viewDidAppear(_ animated: Bool)
    {
        super.viewDidAppear(animated)
        
        print("Starting animation")
        spinningCircleView.startSpinningCircleAnimation()
    }
    
    override func viewDidDisappear(_ animated: Bool)
    {
        super.viewDidDisappear(animated)
        print("Pausing animation")
        spinningCircleView.stopSpinningCircleAnimation()
    }

}

Solution

  • I will answer each of your question

    Why are several hundred calls being made to the startSpinningCircleAnimation() function when the app is put in the background?

    I think because every time you called startSpinningCircleAnimation() in completion and you check state which is inactive, ... of animation before. The things which is wrong here maybe is in the logic of check state in your recursion.

    I will not deep dive into your code to find the wrong one. But I will refactor your SpinningCircleView

    First of all, don't need to use two UIViewPropertyAnimator just for making the view rotate continuously. Simple logic here is just make the view to be rotate and repeat it.

    let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
    rotation.toValue = Double.pi * 2
    rotation.duration = 1
    rotation.isCumulative = true
    rotation.repeatCount = .greatestFiniteMagnitude
    rotation.isRemovedOnCompletion = false
    self.layer.add(rotation, forKey: "rotateInfinityAnimation")
    

    And for your second question

    How can I correctly pause the animation and resume the animation as the app is moved between background and foreground?

    You just need to catch the notification where your app will enter foreground or background from NotificationCenter.

    private func addNotification() {
        let notificationCenter = NotificationCenter.default
        notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.willResignActiveNotification, object: nil)
        notificationCenter.addObserver(self, selector: #selector(appEnterForground), name: UIApplication.didBecomeActiveNotification, object: nil)
    }
    

    For stop and resume animation, you just need to know that when animation happens the layer of the view is the one which occurs the animation. So you just need to add function to stop and resume on the layer of the view. Then every time you want to pause or resume just call from view.layer.resume() or view.layer.stop()

    extension CALayer {
        func pause() {
            let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
            self.speed = 0.0
            self.timeOffset = pausedTime
        }
    
        func resume() {
            let pausedTime: CFTimeInterval = self.timeOffset
            self.speed = 1.0
            self.timeOffset = 0.0
            self.beginTime = 0.0
            let timeSincePause: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
            self.beginTime = timeSincePause
        }
    }
    

    Your SpinningCircleView will be like this

    class SpinningCircleView: UIView {
        private lazy var spinningCircle = CAShapeLayer()
        private var didStopAnimation = false
        
        override init (frame: CGRect)
        {
            super.init(frame: frame)
            configure()
        }
        
        required init?(coder: NSCoder)
        {
            fatalError("init(coder:) has not been implemented")
        }
        
        private func configure()
        {
            didStopAnimation = false
            frame = CGRect(x: 0, y: 0, width: 100, height: 100)
            let rect = self.bounds
            let circularPath = UIBezierPath(ovalIn: rect)
            spinningCircle.path = circularPath.cgPath
            spinningCircle.fillColor = UIColor.clear.cgColor
            spinningCircle.strokeColor = UIColor.systemRed.cgColor
            spinningCircle.lineWidth = 10
            spinningCircle.strokeEnd = 0.25
            spinningCircle.lineCap = .round
            self.layer.addSublayer(spinningCircle)
            self.addNotification()
        }
        
        private func addNotification() {
            let notificationCenter = NotificationCenter.default
            notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.willResignActiveNotification, object: nil)
            notificationCenter.addObserver(self, selector: #selector(appEnterForeground), name: UIApplication.didBecomeActiveNotification, object: nil)
        }
        
        @objc func appEnterForeground() {
            if didStopAnimation {
                return
            }
            
            self.layer.resume()
        }
        
        @objc func appMovedToBackground() {
            if didStopAnimation {
                return
            }
            
            self.layer.pause()
        }
            
        func startSpinningCircleAnimation() {
            if didStopAnimation {
                return
            }
            
            let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
            rotation.toValue = Double.pi * 2
            rotation.duration = 1
            rotation.isCumulative = true
            rotation.repeatCount = .greatestFiniteMagnitude
            rotation.isRemovedOnCompletion = false
            self.layer.add(rotation, forKey: "rotateInfinityAnimation")
        }
        
        
        func stopSpinningCircleAnimation() {
            didStopAnimation = true
            self.layer.removeAllAnimations()
        }
    }