Search code examples
iosswiftuibezierpathcashapelayercabasicanimation

Animate CAShapeLayer with circle UIBezierPath and CABasicAnimation


I'd like to animate a circle from angle 0 to 360 degrees in 15 sec.

The animation is weird. I know this is probably a start/end angle issue, I already faced that kind of problem with circle animations, but I don't know how to solve this one.

var circle_layer=CAShapeLayer()
var circle_anim=CABasicAnimation(keyPath: "path")

func init_circle_layer(){
    let w=circle_view.bounds.width
    let center=CGPoint(x: w/2, y: w/2)

    //initial path
    let start_angle:CGFloat = -0.25*360*CGFloat.pi/180
    let initial_path=UIBezierPath(arcCenter: center, radius: w/2, startAngle: start_angle, endAngle: start_angle, clockwise: true)
    initial_path.addLine(to: center)

    //final path
    let end_angle:CGFloat=start_angle+360*CGFloat(CGFloat.pi/180)
    let final_path=UIBezierPath(arcCenter: center, radius: w/2, startAngle: start_angle, endAngle: end_angle, clockwise: true)
    final_path.addLine(to: center)

    //init layer
    circle_layer.path=initial_path.cgPath
    circle_layer.fillColor=UIColor(hex_code: "EA535D").cgColor
    circle_view.layer.addSublayer(circle_layer)

    //init anim
    circle_anim.duration=15
    circle_anim.fromValue=initial_path.cgPath
    circle_anim.toValue=final_path.cgPath
    circle_anim.isRemovedOnCompletion=false
    circle_anim.fillMode=kCAFillModeForwards
    circle_anim.delegate=self
}

func start_circle_animation(){
    circle_layer.add(circle_anim, forKey: "circle_anim")
}

I want to start on top at 0 degrees and finish on top after a full tour: enter image description here

enter image description here


Solution

  • You can't easily animate the fill of a UIBezierPath (or at least without introducing weird artifacts except in nicely controlled situations). But you can animate the strokeEnd of a path of the CAShapeLayer. And if you make the line width of the stroked path really wide (i.e. the radius of the final circle), and set the radius of the path to be half of that of the circle, you get something like what you're looking for.

    private var circleLayer = CAShapeLayer()
    
    private func configureCircleLayer() {
        let radius = min(circleView.bounds.width, circleView.bounds.height) / 2
    
        circleLayer.strokeColor = UIColor(hexCode: "EA535D").cgColor
        circleLayer.fillColor = UIColor.clear.cgColor
        circleLayer.lineWidth = radius
        circleView.layer.addSublayer(circleLayer)
    
        let center = CGPoint(x: circleView.bounds.width/2, y: circleView.bounds.height/2)
        let startAngle: CGFloat = -0.25 * 2 * .pi
        let endAngle: CGFloat = startAngle + 2 * .pi
        circleLayer.path = UIBezierPath(arcCenter: center, radius: radius / 2, startAngle: startAngle, endAngle: endAngle, clockwise: true).cgPath
    
        circleLayer.strokeEnd = 0
    }
    
    private func startCircleAnimation() {
        circleLayer.strokeEnd = 1
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = 0
        animation.toValue = 1
        animation.duration = 15
        circleLayer.add(animation, forKey: nil)
    }
    

    For ultimate control, when doing complex UIBezierPath animations, you can use CADisplayLink, avoiding artifacts that can sometimes result when using CABasicAnimation of the path:

    private var circleLayer = CAShapeLayer()
    private weak var displayLink: CADisplayLink?
    private var startTime: CFTimeInterval!
    
    private func configureCircleLayer() {
        circleLayer.fillColor = UIColor(hexCode: "EA535D").cgColor
        circleView.layer.addSublayer(circleLayer)
        updatePath(percent: 0)
    }
    
    private func startCircleAnimation() {
        startTime = CACurrentMediaTime()
        displayLink = {
            let _displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
            _displayLink.add(to: .current, forMode: .commonModes)
            return _displayLink
        }()
    }
    
    @objc func handleDisplayLink(_ displayLink: CADisplayLink) {   // the @objc qualifier needed for Swift 4 @objc inference
        let percent = CGFloat(CACurrentMediaTime() - startTime) / 15.0
        updatePath(percent: min(percent, 1.0))
        if percent > 1.0 {
            displayLink.invalidate()
        }
    }
    
    private func updatePath(percent: CGFloat) {
        let w = circleView.bounds.width
        let center = CGPoint(x: w/2, y: w/2)
        let startAngle: CGFloat = -0.25 * 2 * .pi
        let endAngle: CGFloat = startAngle + percent * 2 * .pi
        let path = UIBezierPath()
        path.move(to: center)
        path.addArc(withCenter: center, radius: w/2, startAngle: startAngle, endAngle: endAngle, clockwise: true)
        path.close()
    
        circleLayer.path = path.cgPath
    }
    

    Then you can do:

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    
        configureCircleLayer()
        startCircleAnimation()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
    
        displayLink?.invalidate()   // to avoid displaylink keeping a reference to dismissed view during animation
    }
    

    That yields:

    animated circle