Search code examples
iosswiftcabasicanimation

CABasicAnimation reaches its endpoint faster then the time it's set to


If anyone wants to try this code you can just c+p the file and it will run

I actually have 2 problems in the code below.

1- I have a Timer and a CABasicAnimation that both run when a longPressGesture is triggered. The timer is 15 secs and I decided to use it to just time the animation once I noticed the issue. What's happening is the animation finishes before the timer does. The animation will close/reach its endpoint around 1 sec before the timer finishes AND before CATransaction.setCompletionBlock() and animationDidStop(_:finished) are called. Basically the animation finishes too early.

2- If I take my finger off of the button, the longPressGesture's .cancelled/.ended are called and I pause the timer in invalidateTimer via pauseShapeLayerAnimation(). That was the only way I found to actually stop the animation. When I long press the button again, I restart the timer and animation from the beginning. The issue is because pauseShapeLayerAnimation() is also called when the timer stops (goes to 15secs) CATransaction.setCompletionBlock() are never animationDidStop(_:finished) called. They are only called once I put my finger back on the button.

UPDATE I fixed the second issue by just checking if seconds are != 0 in the invalidateTimer function

import UIKit

class ViewController: UIViewController {
    
    //MARK:- UIElements
    fileprivate lazy var roundButton: UIButton = {
        let button = UIButton(type: .system)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.backgroundColor = UIColor.blue
        return button
    }()
    
    fileprivate lazy var timerLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.monospacedDigitSystemFont(ofSize: 22, weight: .medium)
        label.textColor = UIColor.black
        label.text = initialStrForTimerLabel
        label.textAlignment = .center
        return label
    }()
    
    fileprivate lazy var box: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .brown
        return view
    }()
    
    //MARK:- Properties
    fileprivate let shapeLayer = CAShapeLayer()
    fileprivate let bgShapeLayer = CAShapeLayer()
    fileprivate var basicAnimation: CABasicAnimation!
    
    fileprivate var maxTimeInSecs = 15
    fileprivate lazy var seconds = maxTimeInSecs
    fileprivate var milliseconds = 0
    fileprivate lazy var timerStr = initialStrForTimerLabel
    fileprivate lazy var initialStrForTimerLabel = "\(maxTimeInSecs).0"
    
    fileprivate weak var timer: Timer?
    
    //MARK:- View Controller Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        setAnchors()
        
        setGestures()
    }
    
    fileprivate var wereCAShapeLayersAdded = false
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        if !wereCAShapeLayersAdded {
            wereCAShapeLayersAdded = true
            
            roundButton.layer.cornerRadius = roundButton.frame.width / 2
            
            addBothCAShapeLayersToRoundButton()
        }
    }
    
    //MARK:- Animation Methods
    fileprivate func addBothCAShapeLayersToRoundButton() {
        
        bgShapeLayer.frame = box.bounds
        bgShapeLayer.path = UIBezierPath(rect: box.bounds).cgPath
        bgShapeLayer.strokeColor = UIColor.lightGray.cgColor
        bgShapeLayer.fillColor = UIColor.clear.cgColor
        bgShapeLayer.lineWidth = 6
        box.layer.addSublayer(bgShapeLayer)
        box.layer.insertSublayer(bgShapeLayer, at: 0)
        
        shapeLayer.frame = box.bounds
        shapeLayer.path = UIBezierPath(rect: box.bounds).cgPath
        shapeLayer.strokeColor = UIColor.red.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineWidth = 6
        shapeLayer.lineCap = .round
        shapeLayer.strokeEnd = 0
        
        box.layer.addSublayer(shapeLayer)
    }
    
    fileprivate var isBasicAnimationAnimating = false
    fileprivate func addProgressAnimation() {
        
        CATransaction.begin()
        
        basicAnimation = CABasicAnimation(keyPath: "strokeEnd")
        
        removeAnimation()
        
        if shapeLayer.timeOffset > 0.0 {

            shapeLayer.speed = 1.0
            shapeLayer.timeOffset = 0.0
        }
        
        basicAnimation.delegate = self
        basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
        basicAnimation.fromValue = 0
        basicAnimation.toValue = 1
        basicAnimation.duration = CFTimeInterval(seconds)
        basicAnimation.fillMode = CAMediaTimingFillMode.forwards
        basicAnimation.isRemovedOnCompletion = false
        
        CATransaction.setCompletionBlock {
            print("CATransaction completion called\n")
        }
        
        shapeLayer.add(basicAnimation, forKey: "myAnimation")
        
        CATransaction.commit()
    }
    
    fileprivate func removeAnimation() {
        shapeLayer.removeAnimation(forKey: "myAnimation")
    }
    
    fileprivate func pauseShapeLayerAnimation() {
        
        let pausedTime = shapeLayer.convertTime(CACurrentMediaTime(), from: nil)
        shapeLayer.speed = 0.0
        shapeLayer.timeOffset = pausedTime
        
        print("animation has paused/stopped\n")
    }
    
    //MARK:- Anchors
    fileprivate func setAnchors() {
        
        view.addSubview(box)
        view.addSubview(roundButton)
        view.addSubview(timerLabel)
        
        box.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3).isActive = true
        box.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 3).isActive = true
        box.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -3).isActive = true
        box.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -3).isActive = true
        
        roundButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 0).isActive = true
        roundButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        roundButton.widthAnchor.constraint(equalToConstant: 75).isActive = true
        roundButton.heightAnchor.constraint(equalToConstant: 75).isActive = true
        
        timerLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10).isActive = true
        timerLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        timerLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }
}

//MARK:- CAAnimationDelegate
extension ViewController: CAAnimationDelegate  {
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        print("***** animation done *****\n")
    }
}

//MARK:- Timer Methods
extension ViewController {
    
    fileprivate func startTimer() {
        
        timer?.invalidate()
        
        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] _ in
            self?.timerIsRunning()
        })
    }
    
    @objc fileprivate func timerIsRunning() {
        
        updateTimerLabel()
        
        if !isBasicAnimationAnimating {
            isBasicAnimationAnimating = true
            
            addProgressAnimation()
        }
        
        milliseconds -= 1

        if milliseconds < 0 {

            milliseconds = 9

            if seconds != 0 {
                seconds -= 1

            } else {

                invalidateTimer()

                print("timer done\n")
            }
        }

        if milliseconds == 0 {

            milliseconds = 0
        }
    }
    
    fileprivate func updateTimerLabel() {
        
        let millisecStr = "\(milliseconds)"
        let secondsStr = seconds > 9 ? "\(seconds)" : "0\(seconds)"
        
        timerLabel.text = "\(secondsStr).\(millisecStr)"
    }
    
    fileprivate func resetTimerSecsAndLabel() {
        
        milliseconds = 0
        seconds = maxTimeInSecs
        
        timerLabel.text = initialStrForTimerLabel
    }
    
    fileprivate func invalidateTimer() {
        
        if isBasicAnimationAnimating {
            isBasicAnimationAnimating = false
            
            if seconds != 0 {
                pauseShapeLayerAnimation()
            }
        }
        
        timer?.invalidate()
    }
}

//MARK:- Gestures
extension ViewController {
    
    fileprivate func setGestures() {
        
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGesture))
        roundButton.addGestureRecognizer(tapRecognizer)
        
        let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressGesture))
        roundButton.addGestureRecognizer(longPressRecognizer)
    }
    
    @objc private func tapGesture(recognizer: UITapGestureRecognizer) {
        print("tap\n")
    }
    
    @objc private func longPressGesture(recognizer: UILongPressGestureRecognizer) {
        
        switch recognizer.state {
        case .began:
            resetTimerSecsAndLabel()
            startTimer()
            print("long gesture began\n")
        case .ended, .cancelled:
            invalidateTimer()
            print("long gesture ended or cancelled\n")
        case .failed:
            print("long gesture failed\n")
        default:
            break
        }
    }
}

Solution

  • I think the animation finishing early is an illusion caused by three factors:

    1. You are using CAMediaTimingFunctionName.easeInEaseOut which means the drawing starts slow and ends slow making it hard to judge the real end of drawing.
    2. The drawing finishes by drawing over the start of the line which also makes it hard to see exactly when drawing stops.
    3. Your timer should be subtracting 0.1 from the time before updating the label, because 0.1 has already passed when the timer first updates.

    When I changed the timing function to CAMediaTimingFunctionName.linear and fixed the timer, it seemed to always hit 0 when the drawing finished.