9

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

Marie Dm
  • 2,637
  • 3
  • 24
  • 43
  • This will give you some insights https://stackoverflow.com/questions/35822790/uibezierpath-cashapelayer-animate-a-circle-filling-up – Anil Varghese Jul 25 '17 at 16:32

1 Answers1

16

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

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • I was using a `Timer` before and I'd like to remove it. Since I also display a video at the same time, I want the animation to run smoothly and avoid screen freezing (video freezing, circle animation freezing or anything UI freezing). I felt like a `Timer` was to "heavy" since it has to update every 0.01 sec (in my case) on the main thread. Is `CADisplayLink` really different from a `Timer`? Is it a better solution? – Marie Dm Jul 25 '17 at 17:02
  • 1
    Yes, `CADisplayLink` is better than `Timer` (because it's optimally timed at the start of every screen refresh cycle). But it's still likely to be more computationally intensive than `CABasicAnimation`. But, if you don't mind kludgy approach, since you can't easily animate the fill of a `CAShapeLayer`, you can animate a `strokeEnd` of a path (with no fill) with `CABasicAnimation`. Just make the path of the stroked path to be half of the desired radius, but then set the `lineWidth` of the stroke to be the desired radius. It's counter-intuitive, but yields the desired effect. See revised answer. – Rob Jul 25 '17 at 17:56
  • Yes, that's a possibility I was considering. I'll try that and come back to you. – Marie Dm Jul 25 '17 at 18:12