This question already has an answer here:
- Draw part of a circle 8 answers
- iPhone Core Animation - Drawing a Circle 4 answers
- iOS: How to draw a circle step by step with NSTimer 2 answers
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%)
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()
}
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:

(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.
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.
So I took what Duncan C had in his linked GitHub project (in Objective-C) and applied it to my scenario (also converted it to Swift) - and it works great!
Here's the final solution:
// ---
// In some ViewController
// ---
let animatedCircle = AnimatedCircle()
self.addSubview(animatedCircle)
animatedCircle.runAnimation()
// ---
// The Animated Circle Class
// ---
import UIKit
import Darwin
let pi: CGFloat = CGFloat(M_PI)
let startAngle: CGFloat = (3.0 * pi) / 2.0
let colorGray = UIColor(red: 0.608, green: 0.608, blue: 0.608, alpha: 1.000)
let colorGreen = UIColor(red: 0.482, green: 0.835, blue: 0.000, alpha: 1.000)
// ----
// Math class to handle fun circle forumals
// ----
class Math {
func percentToRadians(percentComplete: CGFloat) -> CGFloat {
let degrees = (percentComplete/100) * 360
return startAngle + (degrees * (pi/180))
}
}
class AnimatedCircle: UIView {
let percentComplete: CGFloat = 25
var containerView: UIView!
var filledLayer: CAShapeLayer!
init() {
super.init(frame: CGRect(x: 0, y: 0, width: 260, height: 260))
let endAngle = Math().percentToRadians(percentComplete)
// ----
// Create oval bezier path and layer
// ----
let ovalPath = UIBezierPath(ovalInRect: CGRectMake(10, 10, 240, 240))
let circleStrokeLayer = CAShapeLayer()
circleStrokeLayer.path = ovalPath.CGPath
circleStrokeLayer.lineWidth = 20
circleStrokeLayer.fillColor = UIColor.clearColor().CGColor
circleStrokeLayer.strokeColor = colorGreen.CGColor
// ----
// Create filled bezier path and layer
// ----
let filledPathStart = UIBezierPath(arcCenter: CGPoint(x: 140, y: 140), radius: 120, startAngle: startAngle, endAngle: endAngle, clockwise: true)
filledPathStart.addLineToPoint(CGPoint(x: 140, y: 140))
filledLayer = CAShapeLayer()
filledLayer.path = filledPathStart.CGPath
filledLayer.fillColor = colorGreen.CGColor
// ----
// Add any layers to container view
// ----
containerView = UIView(frame: self.frame)
containerView.backgroundColor = UIColor.whiteColor()
containerView.layer.addSublayer(circleStrokeLayer)
containerView.layer.addSublayer(filledLayer)
// Set the frame of the filledLayer to match our view
filledLayer.frame = containerView.frame
}
func runAnimation() {
let endAngle = Math().percentToRadians(percentComplete)
let maskLayer = CAShapeLayer()
let maskWidth: CGFloat = filledLayer.frame.size.width
let maskHeight: CGFloat = filledLayer.frame.size.height
let centerPoint: CGPoint = CGPointMake(maskWidth / 2, maskHeight / 2)
let radius: CGFloat = CGFloat(sqrtf(Float(maskWidth * maskWidth + maskHeight * maskHeight)) / 2)
maskLayer.fillColor = UIColor.clearColor().CGColor
maskLayer.strokeColor = UIColor.blackColor().CGColor
maskLayer.lineWidth = radius
let arcPath: CGMutablePathRef = CGPathCreateMutable()
CGPathMoveToPoint(arcPath, nil, centerPoint.x, centerPoint.y - radius / 2)
CGPathAddArc(arcPath, nil, centerPoint.x, centerPoint.y, radius / 2, 3*pi/2, endAngle, false)
maskLayer.path = arcPath
maskLayer.strokeEnd = 0
filledLayer.mask = maskLayer
filledLayer.mask!.frame = filledLayer.frame
let anim = CABasicAnimation(keyPath: "strokeEnd")
anim.duration = 3
anim.delegate = self
anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
anim.fillMode = kCAFillModeForwards
anim.removedOnCompletion = false
anim.autoreverses = false
anim.toValue = 1.0
maskLayer.addAnimation(anim, forKey: "strokeEnd")
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
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.
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")
}
}
来源:https://stackoverflow.com/questions/35822790/uibezierpath-cashapelayer-animate-a-circle-filling-up