UIBezierPath + CAShapeLayer - animate a circle filling up

Viewed 8423

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()
}
3 Answers

Thanks, Duncan C - that was super helpful.

Here is an example of a progress circle using your suggestion. The view draws a progress circle that animates over time, starting out as a full circle and erasing itself to reveal the view behind the circle. There are @IBInspectable properties that can be modified to fill the circle instead of erasing.

circle timer gif

Here is a link to the project. The view is in CircleTimer.view. If you run the project, you can enter a number of seconds and see the circle erase over the specified time.

https://github.com/riley2012/circle-timer-ios

This is the method that does the animation:

    open func runMaskAnimation(duration: CFTimeInterval) {

    if let parentLayer = filledLayer {
        let maskLayer = CAShapeLayer()
        maskLayer.frame = parentLayer.frame

        let circleRadius = timerFillDiameter * 0.5
        let circleHalfRadius = circleRadius * 0.5
        let circleBounds = CGRect(x: parentLayer.bounds.midX - circleHalfRadius, y: parentLayer.bounds.midY - circleHalfRadius, width: circleRadius, height: circleRadius)

        maskLayer.fillColor = UIColor.clear.cgColor
        maskLayer.strokeColor = UIColor.black.cgColor
        maskLayer.lineWidth = circleRadius

        let path = UIBezierPath(roundedRect: circleBounds, cornerRadius: circleBounds.size.width * 0.5)
        maskLayer.path = path.reversing().cgPath
        maskLayer.strokeEnd = 0

        parentLayer.mask = maskLayer

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.duration = duration
        animation.fromValue = 1.0
        animation.toValue = 0.0
        maskLayer.add(animation, forKey: "strokeEnd")
    }

}

Related