Search code examples
swiftanimationcabasicanimation

Linear CABasicAnimation isn't executed linearly in Container View


Even after setting the timingFunction explicitly to linear to animation is not executed linearly.

I use the following code to initialize the animation.

Further down is the implementation of the whole class and how the ViewController is set up in the InterfaceBuilder

private func timeLayerAnimation() {
    let animation = CABasicAnimation()
    animation.keyPath = "strokeEnd"
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
    animation.duration = 240.0
    animation.toValue = 0
    animation.fillMode = kCAFillModeForwards
    animation.isRemovedOnCompletion = false

    timeLayer.add(animation, forKey: nil)
}

The view looks like the following. enter image description here

The total animation duration is 240 seconds.

But after 30 sec. already only 75 % of the circle remain visible.

The stopped times are as follows:

75 % (1.5 π):    30  sec. (∆ 30 sec.)

50 % (1 π):      70  sec. (∆ 40 sec.)

25 % (0.5 π):    120 sec. (∆ 50 sec.)

// 13 % (0.25 π):   155 sec.

0  % (0 π):      240 sec. (∆ 120 sec.)

Update

I found that the problem occurs when the ViewController responsible for the animation lives inside a container view.

My guess is that it could have something to do with the default UIViewAnimationCurve, but I'm not sure and I don't know how where to start to test that :(

enter image description here

All sides of the Container View are pinned to the safe area. The Implementation of the MainVC is empty and the EmbeddedVC looks as follows:

import UIKit


class EmbeddedVC: UIViewController {
    // MARK: - Properties

    let timeLayer = CAShapeLayer()

    // MARK: - View Lifecycle

    override func viewDidLayoutSubviews() {
        setupTimerLayout()
    }

}

// MARK: - Timer Layout Setup
extension EmbeddedVC {

    func setupTimerLayout() {
        let circularPath = UIBezierPath.init(arcCenter: .zero,
                                             radius: view.frame.width * 0.36,
                                             startAngle: 0,
                                             endAngle: 2 * CGFloat.pi,
                                             clockwise: true)

        // Configure time layer
        timeLayer.path = circularPath.cgPath
        timeLayer.fillColor = UIColor.clear.cgColor
        timeLayer.lineCap = kCALineCapRound
        timeLayer.strokeColor = UIColor.red.cgColor
        timeLayer.strokeEnd = 1
        timeLayer.lineWidth = 10
        timeLayer.position = view.center

        view.layer.addSublayer(timeLayer)
        animateTimeLayer()
    }


    private func animateTimeLayer() {
        let animation = CABasicAnimation()
        animation.keyPath = "strokeEnd"
        animation.duration = 240.0
        animation.toValue = 0
        animation.fillMode = kCAFillModeForwards
        animation.isRemovedOnCompletion = false

        timeLayer.add(animation, forKey: nil)
    }

}

Solution

  • The problem is that viewDidLayoutSubviews can be called multiple times and you are not specifying fromValue so these two animations are interfering with each other. You can solve this any combination of the below:

    1. Add a check to see if you started the animation already:

      var hasStarted = false
      
      private func startAnimation() {            
          guard !hasStarted else { return }
      
          hasStarted = true
      
          ...
      }
      

      (Note, I'd suggest you always call super when overriding these methods.)

    2. Remove the animation before starting another animation.

    3. Specify a fromValue in your animation:

      private func startAnimation() {
          let animation = CABasicAnimation(keyPath: "strokeEnd")
          animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
          animation.duration = ...
          animation.fromValue = 1  // by setting this, it won't get confused if you start the animation again
          animation.toValue = 0
          ...
      
          shapeLayer.add(animation, forKey: nil)
      }
      
    4. Defer this until viewDidAppear:

      override func viewDidAppear(_ animated: Bool) {
          super.viewDidAppear(animated)
      
          // start your animation here
      }
      

    Personally, I'd do both #3 and #4, but you can do whatever works best for you.