UIBezierPath + CAShapeLayer - animate a circle filling up

Alex picture Alex · Mar 6, 2016 · Viewed 7.8k times · Source

I am trying to animate the paths of a CAShapeLayer so that I get the effect of a circle "filling" up to a specific amount.

The Issue

It "works", but is not AS smooth as I think it could be, and I want to introduce some easing to it. But because I'm animating each % individually, I'm not sure that's possible right now. So I'm looking for alternatives that could solve this issue!

Visual Explanation

To start -- here are some "frames" of the animation that I'm trying to achieve (see images - circle fills from 0% to 25%)

enter image description here enter image description here enter image description here enter image description here enter image description here

Code

I create the green stroke (outside):

let ovalPath = UIBezierPath(ovalInRect: CGRectMake(20, 20, 240, 240))
let circleStrokeLayer = CAShapeLayer()
circleStrokeLayer.path = ovalPath.CGPath
circleStrokeLayer.lineWidth = 20
circleStrokeLayer.fillColor = UIColor.clearColor().CGColor
circleStrokeLayer.strokeColor = colorGreen.CGColor

containerView.layer.addSublayer(circleStrokeLayer)

I create the initial path of the filled shape (inside):

let filledPathStart = UIBezierPath(arcCenter: CGPoint(x: 140, y: 140), radius: 120, startAngle: startAngle, endAngle: Math().percentToRadians(0), clockwise: true)
filledPathStart.addLineToPoint(CGPoint(x: 140, y: 140))
filledLayer = CAShapeLayer()
filledLayer.path = filledPathStart.CGPath
filledLayer.fillColor = colorGreen.CGColor
containerView.layer.addSublayer(filledLayer)

I then animate with a for loop and array of CABasicAnimations.

var animations: [CABasicAnimation] = [CABasicAnimation]()

func animate() {

    for val in 0...25 {
        let morph = CABasicAnimation(keyPath: "path")
        morph.duration = 0.01
        let filledPathEnd = UIBezierPath(arcCenter: CGPoint(x: 140, y: 140), radius: 120, startAngle: startAngle, endAngle: Math().percentToRadians(CGFloat(val)), clockwise: true)
        filledPathEnd.addLineToPoint(CGPoint(x: 140, y: 140))

        morph.delegate = self
        morph.toValue = filledPathEnd.CGPath
        animations.append(morph)
    }

    applyNextAnimation()
}

func applyNextAnimation() {

    if animations.count == 0 {
        return
    }

    let nextAnimation = animations[0]
    animations.removeAtIndex(0)
    filledLayer.addAnimation(nextAnimation, forKey: nil)
}

override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
    filledLayer.path = (anim as! CABasicAnimation).toValue as! CGPath
    applyNextAnimation()
}

Answer

Duncan C picture Duncan C · Mar 6, 2016

Don't try to create a whole bunch of separate animations. CAShapeLayer has a strokeStart and strokeEnd property. Animate that.

The trick is to install an arc of the whole circle in the shape layer, then create a CABasicAnimation that animates the shapeEnd from 0 to 1 (to animate the fill from 0% to 100%) or whatever values you need.

You can apply whatever timing you want on that animation.

I have a project (In Objective-C, I'm afraid) on Github that includes a "clock wipe" animation using this technique. Here's how it looks:

Clock Wipe animation

(That's a gif, which looks a little rough. The actual iOS animation is quite smooth.)

The link is below. Look for the "clock wipe" animation in the readme.

iOS-CAAnimation-group-demo

The clock wipe animation installs the shape layer as a mask on an image view's layer. You can instead draw your shape layer directly if that's what you want to do.