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.
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 :(
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)
}
}
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:
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.)
Remove the animation before starting another animation.
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)
}
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.