CABasicAnimation 比它设置的时间更快地到达它的端点

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

如果有人想尝试此代码,您只需 c+p 文件即可 运行

实际上我在下面的代码中有 2 个问题。

1- 我有一个 Timer 和一个 CABasicAnimation,当 longPressGesture 被触发时,它们都 运行。计时器是 15 秒,一旦我注意到这个问题,我决定用它来为动画计时。发生的事情是动画在计时器完成之前完成。动画将 close/reach 在计时器结束前约 1 秒和 CATransaction.setCompletionBlock()animationDidStop(_:finished) 被调用之前的终点。基本上动画结束得太早了。

2- 如果我将手指从按钮上移开,则会调用 longPressGesture 的 .cancelled/.ended 并且我会通过 pauseShapeLayerAnimation()invalidateTimer 中暂停计时器。这是我发现真正停止动画的唯一方法。当我再次长按按钮时,我从头开始重新启动计时器和动画。问题是因为 pauseShapeLayerAnimation() 也会在计时器停止(达到 15 秒)时调用 CATransaction.setCompletionBlock() 永远不会 animationDidStop(_:finished) 被调用。只有当我将手指放回按钮上时,它们才会被调用。

UPDATE 我通过检查 invalidateTimer 函数中的秒数是否为 != 0 解决了第二个问题

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
        }
    }
}

我认为动画提前完成是三个因素造成的错觉:

  1. 您正在使用 CAMediaTimingFunctionName.easeInEaseOut 这意味着绘图开始缓慢并且结束缓慢,因此很难判断绘图的真正结束。
  2. 绘图通过在线的起点绘制完成,这也使得很难准确看到绘图何时停止。
  3. 您的计时器应该从 之前的时间减去 0.1 更新标签,因为当计时器第一次更新时 0.1 已经过去了。

当我将计时功能更改为CAMediaTimingFunctionName.linear并修复计时器时,似乎总是在绘图完成时点击0