Animated CAShapeLayer Pie

11,509

Solution 1

I know this question has long been answered, but I don't quite think this is a good case for CAShapeLayer and CAKeyframeAnimation. Core Animation has the power to do animation tweening for us. Here's a class (with a wrapping UIView, if you like) that I use to accomplish the effect pretty well.

The layer subclass enables implicit animation for the progress property, but the view class wraps its setter in a UIView animation method. The interesting (and ultimately useful) side effect of using a 0.0 length animation with UIViewAnimationOptionBeginFromCurrentState is that each animation cancels out the previous one, leading to a smooth, fast, high-framerate pie, like this (animated) and this (not animated, but incremented).

DZRoundProgressView.h

@interface DZRoundProgressLayer : CALayer

@property (nonatomic) CGFloat progress;

@end

@interface DZRoundProgressView : UIView

@property (nonatomic) CGFloat progress;

@end

DZRoundProgressView.m

#import "DZRoundProgressView.h"
#import <QuartzCore/QuartzCore.h>

@implementation DZRoundProgressLayer

// Using Core Animation's generated properties allows
// it to do tweening for us.
@dynamic progress;

// This is the core of what does animation for us. It
// tells CoreAnimation that it needs to redisplay on
// each new value of progress, including tweened ones.
+ (BOOL)needsDisplayForKey:(NSString *)key {
    return [key isEqualToString:@"progress"] || [super needsDisplayForKey:key];
}

// This is the other crucial half to tweening.
// The animation we return is compatible with that
// used by UIView, but it also enables implicit
// filling-up-the-pie animations.
- (id)actionForKey:(NSString *) aKey {
    if ([aKey isEqualToString:@"progress"]) {
        CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:aKey];
        animation.fromValue = [self.presentationLayer valueForKey:aKey];
        return animation;
    }
    return [super actionForKey:aKey];
}

// This is the gold; the drawing of the pie itself.
// In this code, it draws in a "HUD"-y style, using
// the same color to fill as the border.
- (void)drawInContext:(CGContextRef)context {
    CGRect circleRect = CGRectInset(self.bounds, 1, 1);

    CGColorRef borderColor = [[UIColor whiteColor] CGColor];
    CGColorRef backgroundColor = [[UIColor colorWithWhite: 1.0 alpha: 0.15] CGColor];

    CGContextSetFillColorWithColor(context, backgroundColor);
    CGContextSetStrokeColorWithColor(context, borderColor);
    CGContextSetLineWidth(context, 2.0f);

    CGContextFillEllipseInRect(context, circleRect);
    CGContextStrokeEllipseInRect(context, circleRect);

    CGFloat radius = MIN(CGRectGetMidX(circleRect), CGRectGetMidY(circleRect));
    CGPoint center = CGPointMake(radius, CGRectGetMidY(circleRect));
    CGFloat startAngle = -M_PI / 2;
    CGFloat endAngle = self.progress * 2 * M_PI + startAngle;
    CGContextSetFillColorWithColor(context, borderColor);
    CGContextMoveToPoint(context, center.x, center.y);
    CGContextAddArc(context, center.x, center.y, radius, startAngle, endAngle, 0);
    CGContextClosePath(context);
    CGContextFillPath(context);

    [super drawInContext:context];
}

@end

@implementation DZRoundProgressView
+ (Class)layerClass {
    return [DZRoundProgressLayer class];
}

- (id)init {
    return [self initWithFrame:CGRectMake(0.0f, 0.0f, 37.0f, 37.0f)];
}

- (id)initWithFrame:(CGRect)frame {
    if ((self = [super initWithFrame:frame])) {
        self.opaque = NO;
        self.layer.contentsScale = [[UIScreen mainScreen] scale];
        [self.layer setNeedsDisplay];
    }
    return self;
}

- (void)setProgress:(CGFloat)progress {
    [(id)self.layer setProgress:progress];
}

- (CGFloat)progress {
    return [(id)self.layer progress];
}

@end

Solution 2

I suggest you make a keyframe animation instead:

pie.bounds = CGRectMake(-0.5 * radius,
                        -0.5 * radius,
                        radius,
                        radius);

NSMutableArray *values = [NSMutableArray array];
for (int i = 0; i < nrSteps + 1; i++)
{
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path moveToPoint:CGPointZero];
    [path addLineToPoint:CGPointMake(radius * cosf(startAngle),
                                     radius * sinf(startAngle))];
    [path addArcWithCenter:...
                  endAngle:startAngle + i * (endAngle - startAngle) / nrSteps
                           ...];
    [path closePath];
    [values addObject:(__bridge id)path.CGPath];
}

Basic animation is good for scalars/vectors. But do you want it to interpolate your paths?

Solution 3

Quick copy/paste Swift translation of this excellent Objective-C answer.

class ProgressView: UIView {

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        genericInit()
    }

    private func genericInit() {
        self.opaque = false;
        self.layer.contentsScale = UIScreen.mainScreen().scale
        self.layer.setNeedsDisplay()
    }

    var progress : CGFloat = 0 {
        didSet {
            (self.layer as! ProgressLayer).progress = progress
        }
    }

    override class func layerClass() -> AnyClass {
        return ProgressLayer.self
    }

    func updateWith(progress : CGFloat) {
        self.progress = progress
    }
}

class ProgressLayer: CALayer {

    @NSManaged var progress : CGFloat

    override class func needsDisplayForKey(key: String!) -> Bool{

        return key == "progress" || super.needsDisplayForKey(key);
    }

    override func actionForKey(event: String!) -> CAAction! {

        if event == "progress" {
            let animation = CABasicAnimation(keyPath: event)
            animation.duration = 0.2
            animation.fromValue = self.presentationLayer().valueForKey(event)
            return animation
        }

        return super.actionForKey(event)
    }

    override func drawInContext(ctx: CGContext!) {

        if progress != 0 {

            let circleRect = CGRectInset(self.bounds, 1, 1)
            let borderColor = UIColor.whiteColor().CGColor
            let backgroundColor = UIColor.clearColor().CGColor

            CGContextSetFillColorWithColor(ctx, backgroundColor)
            CGContextSetStrokeColorWithColor(ctx, borderColor)
            CGContextSetLineWidth(ctx, 2)

            CGContextFillEllipseInRect(ctx, circleRect)
            CGContextStrokeEllipseInRect(ctx, circleRect)

            let radius = min(CGRectGetMidX(circleRect), CGRectGetMidY(circleRect))
            let center = CGPointMake(radius, CGRectGetMidY(circleRect))
            let startAngle = CGFloat(-(M_PI/2))
            let endAngle = CGFloat(startAngle + 2 * CGFloat(M_PI * Double(progress)))

            CGContextSetFillColorWithColor(ctx, borderColor)
            CGContextMoveToPoint(ctx, center.x, center.y)
            CGContextAddArc(ctx, center.x, center.y, radius, startAngle, endAngle, 0)
            CGContextClosePath(ctx)
            CGContextFillPath(ctx)
        }
    }
}

Solution 4

In Swift 3

class ProgressView: UIView {

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        genericInit()
    }

    private func genericInit() {
        self.isOpaque = false;
        self.layer.contentsScale = UIScreen.main.scale
        self.layer.setNeedsDisplay()
    }

    var progress : CGFloat = 0 {
        didSet {
            (self.layer as! ProgressLayer).progress = progress
        }
    }

    override class var layerClass: AnyClass {
        return ProgressLayer.self
    }

    func updateWith(progress : CGFloat) {
        self.progress = progress
    }
}

class ProgressLayer: CALayer {

    @NSManaged var progress : CGFloat

    override class func needsDisplay(forKey key: String) -> Bool{

        return key == "progress" || super.needsDisplay(forKey: key);
    }

    override func action(forKey event: String) -> CAAction? {

        if event == "progress" {

            let animation = CABasicAnimation(keyPath: event)
            animation.duration = 0.2
            animation.fromValue = self.presentation()?.value(forKey: event) ?? 0
            return animation

        }

        return super.action(forKey: event)
    }

    override func draw(in ctx: CGContext) {

        if progress != 0 {

            let circleRect = self.bounds.insetBy(dx: 1, dy: 1)
            let borderColor = UIColor.white.cgColor
            let backgroundColor = UIColor.red.cgColor

            ctx.setFillColor(backgroundColor)
            ctx.setStrokeColor(borderColor)
            ctx.setLineWidth(2)

            ctx.fillEllipse(in: circleRect)
            ctx.strokeEllipse(in: circleRect)

            let radius = min(circleRect.midX, circleRect.midY)
            let center = CGPoint(x: radius, y: circleRect.midY)
            let startAngle = CGFloat(-(Double.pi/2))
            let endAngle = CGFloat(startAngle + 2 * CGFloat(Double.pi * Double(progress)))

            ctx.setFillColor(borderColor)
            ctx.move(to: CGPoint(x:center.x , y: center.y))
            ctx.addArc(center: CGPoint(x:center.x, y: center.y), radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            ctx.closePath()
            ctx.fillPath()
        }
    }
}
Share:
11,509
Mike A
Author by

Mike A

iOS and Rails dev. Lawyer.

Updated on July 27, 2022

Comments

  • Mike A
    Mike A almost 2 years

    I am trying to create a simple and animated pie chart using CAShapeLayer. I want it to animate from 0 to a provided percentage.

    To create the shape layer I use:

    CGMutablePathRef piePath = CGPathCreateMutable();
    CGPathMoveToPoint(piePath, NULL, self.frame.size.width/2, self.frame.size.height/2);
    CGPathAddLineToPoint(piePath, NULL, self.frame.size.width/2, 0);
    CGPathAddArc(piePath, NULL, self.frame.size.width/2, self.frame.size.height/2, radius, DEGREES_TO_RADIANS(-90), DEGREES_TO_RADIANS(-90), 0);        
    CGPathAddLineToPoint(piePath, NULL, self.frame.size.width/2 + radius * cos(DEGREES_TO_RADIANS(-90)), self.frame.size.height/2 + radius * sin(DEGREES_TO_RADIANS(-90)));
    
    pie = [CAShapeLayer layer];
    pie.fillColor = [UIColor redColor].CGColor;
    pie.path = piePath;
    
    [self.layer addSublayer:pie];
    

    Then to animate I use:

    CGMutablePathRef newPiePath = CGPathCreateMutable();
    CGPathAddLineToPoint(newPiePath, NULL, self.frame.size.width/2, 0);    
    CGPathMoveToPoint(newPiePath, NULL, self.frame.size.width/2, self.frame.size.height/2);
    CGPathAddArc(newPiePath, NULL, self.frame.size.width/2, self.frame.size.height/2, radius, DEGREES_TO_RADIANS(-90), DEGREES_TO_RADIANS(125), 0);                
    CGPathAddLineToPoint(newPiePath, NULL, self.frame.size.width/2 + radius * cos(DEGREES_TO_RADIANS(125)), self.frame.size.height/2 + radius * sin(DEGREES_TO_RADIANS(125)));    
    
    CABasicAnimation *pieAnimation = [CABasicAnimation animationWithKeyPath:@"path"];
    pieAnimation.duration = 1.0;
    pieAnimation.removedOnCompletion = NO;
    pieAnimation.fillMode = kCAFillModeForwards;
    pieAnimation.fromValue = pie.path;
    pieAnimation.toValue = newPiePath;
    
    [pie addAnimation:pieAnimation forKey:@"animatePath"];
    

    Obviously, this is animating in a really odd way. The shape just kind of grows into its final state. Is there an easy way to make this animation follow the direction of the circle? Or is that a limitation of CAShapeLayer animations?

  • abhi
    abhi almost 12 years
    I'm not getting the animation to work. could you attach a link to a sample code. (GIT hub..or something else)
  • zwaldowski
    zwaldowski almost 12 years
    Sorry about that! I've updated the code, and future updates are available in an open source piece code of mine.
  • samson
    samson over 11 years
    Wait, WHY is this better than CAShapeLayer? Wouldn't CAShapeLayer be a lot faster?
  • zwaldowski
    zwaldowski over 11 years
    CAShapeLayer might be ever-so-slightly faster, but not by much if you were to use it in this same way (redrawing on frame and progress change), with the addition that with CAShapeLayer animation between paths wouldn't work. CAShapeLayer's default tweened animation is a "morph" rather than a "grow", and if you were to do it yourself using a keyframe animation, it'd be equally expensive and all on the CPU. This isn't the fastest way to do it, but it's still reasonably fast because we're not drawing shadows or anything.
  • Drux
    Drux almost 10 years
    +1 excellent. Could you please extend the code such that it allows also reverse animations as process decreases. I'm currently facing problems with applying clearColor to sectors.
  • Drux
    Drux almost 10 years
    I have posted a follow-up question here
  • Vincenzo
    Vincenzo about 8 years
    I would like to have this pie animation embedded in a video "AVExportSession". Therefore the animation duration should be the time defining the progress from 0 ... 1. I am not able to figure out how to do that. If I set .fromValue = 0 and .toValue = 1 the pie is already fully filled at the beginning of the animation.