UIBezierPath + CAShapeLayer - animate a circle fil

2019-02-07 16:44发布

This question already has an answer here:

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条回答
Emotional °昔
2楼-- · 2019-02-07 17:11

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.

查看更多
Melony?
3楼-- · 2019-02-07 17:25

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")
    }
}
查看更多
放我归山
4楼-- · 2019-02-07 17:33

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")
    }

}

查看更多
登录 后发表回答