I\'ve created a circular animation using CAShapeLayer and masking. Here is my code:
- (void) maskAnimation{
animationCompletionBlock theBlock;
imageVie
Unfortunately, there are no options in path drawing to have a pointed line cap like the one you describe (options are available using CAShapeLayer
's lineCap
property, just not the one you need).
You will have to draw the path boundary yourself and fill it, instead of relying on the width of the stroke. This means 3 lines and 2 arcs, which should be manageable, although not as straightforward as what you tried to do.
See my other answer for a solution that doesn't have glitches.
This is a fun little problem. I don't think we can solve it perfectly with just Core Animation, but we can do pretty well.
We should set up the mask when the view is laid out, so we only have to do it when the image view first appears or when it changes size. So let's do it from viewDidLayoutSubviews
:
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
[self setUpMask];
}
- (void)setUpMask {
arrowLayer = [self arrowLayerWithFrame:imageView.bounds];
imageView.layer.mask = arrowLayer;
}
Here, arrowLayer
is an instance variable, so I can animate the layer.
To actually create the arrow-shaped layer, I need some constants:
static CGFloat const kThickness = 60.0f;
static CGFloat const kTipRadians = M_PI_2 / 8;
static CGFloat const kStartRadians = -M_PI_2;
static CGFloat const kEndRadians = kStartRadians + 2 * M_PI;
static CGFloat const kTipStartRadians = kEndRadians - kTipRadians;
Now I can create the layer. Since there's no “arrow-shaped” line end cap, I have to make a path that outlines the whole path, including the pointy tip:
- (CAShapeLayer *)arrowLayerWithFrame:(CGRect)frame {
CGRect bounds = (CGRect){ CGPointZero, frame.size };
CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
CGFloat outerRadius = bounds.size.width / 2;
CGFloat innerRadius = outerRadius - kThickness;
CGFloat pointRadius = outerRadius - kThickness / 2;
UIBezierPath *path = [UIBezierPath bezierPath];
[path addArcWithCenter:center radius:outerRadius startAngle:kStartRadians endAngle:kTipStartRadians clockwise:YES];
[path addLineToPoint:CGPointMake(center.x + pointRadius * cosf(kEndRadians), center.y + pointRadius * sinf(kEndRadians))];
[path addArcWithCenter:center radius:innerRadius startAngle:kTipStartRadians endAngle:kStartRadians clockwise:NO];
[path closePath];
CAShapeLayer *layer = [CAShapeLayer layer];
layer.frame = frame;
layer.path = path.CGPath;
layer.fillColor = [UIColor whiteColor].CGColor;
layer.strokeColor = nil;
return layer;
}
If we do all that, it looks like this:
Now, we want the arrow to go around, so we apply a rotation animation to the mask:
- (IBAction)goButtonWasTapped:(UIButton *)goButton {
goButton.enabled = NO;
[CATransaction begin]; {
[CATransaction setAnimationDuration:2];
[CATransaction setCompletionBlock:^{
goButton.enabled = YES;
}];
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.autoreverses = YES;
animation.fromValue = 0;
animation.toValue = @(2 * M_PI);
[arrowLayer addAnimation:animation forKey:animation.keyPath];
} [CATransaction commit];
}
When we tap the Go button, it looks like this:
That's not right, of course. We need to clip the arrow tail. To do that, we need to apply a mask to the mask. We can't apply it directly (I tried). Instead, we need an extra layer to act as the image view's mask. The hierarchy looks like this:
Image view layer
Mask layer (just a generic `CALayer` set as the image view layer's mask)
Arrow layer (a `CAShapeLayer` as a regular sublayer of the mask layer)
Ring layer (a `CAShapeLayer` set as the mask of the arrow layer)
The new ring layer will be just like your original attempt to draw the mask: a single stroked ARC segment. We'll set up the hierarchy by rewriting setUpMask
:
- (void)setUpMask {
CALayer *layer = [CALayer layer];
layer.frame = imageView.bounds;
imageView.layer.mask = layer;
arrowLayer = [self arrowLayerWithFrame:layer.bounds];
[layer addSublayer:arrowLayer];
ringLayer = [self ringLayerWithFrame:arrowLayer.bounds];
arrowLayer.mask = ringLayer;
return;
}
We now have another ivar, ringLayer
, because we'll need to animate that too. The arrowLayerWithFrame:
method is unchanged. Here's how we create the ring layer:
- (CAShapeLayer *)ringLayerWithFrame:(CGRect)frame {
CGRect bounds = (CGRect){ CGPointZero, frame.size };
CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
CGFloat radius = (bounds.size.width - kThickness) / 2;
CAShapeLayer *layer = [CAShapeLayer layer];
layer.frame = frame;
layer.path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:kStartRadians endAngle:kEndRadians clockwise:YES].CGPath;
layer.fillColor = nil;
layer.strokeColor = [UIColor whiteColor].CGColor;
layer.lineWidth = kThickness + 2; // +2 to avoid extra anti-aliasing
layer.strokeStart = 1;
return layer;
}
Note that we're setting the strokeStart
to 1, instead of setting the strokeEnd
to 0. The stroke end is at the tip of the arrow, and we always want the tip to be visible, so we leave it alone.
Finally, we rewrite goButtonWasTapped
to animate the ring layer's strokeStart
(in addition to animating the arrow layer's rotation):
- (IBAction)goButtonWasTapped:(UIButton *)goButton {
goButton.hidden = YES;
[CATransaction begin]; {
[CATransaction setAnimationDuration:2];
[CATransaction setCompletionBlock:^{
goButton.hidden = NO;
}];
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.autoreverses = YES;
animation.fromValue = 0;
animation.toValue = @(2 * M_PI);
[arrowLayer addAnimation:animation forKey:animation.keyPath];
animation.keyPath = @"strokeStart";
animation.fromValue = @1;
animation.toValue = @0;
[ringLayer addAnimation:animation forKey:animation.keyPath];
} [CATransaction commit];
}
The end result looks like this:
It's still not perfect. There's a little wiggle at the tail and sometimes you get a column of blue pixels there. At the tip you also sometimes get a whisper of a white line. I think this is due to the way Core Animation represents the arc internally (as a cubic Bezier spline). It can't perfectly measure the distance along the path for strokeStart
, so it approximates, and sometimes the approximation is off by enough to leak some pixels. You can fix the tip problem by changing kEndRadians
to this:
static CGFloat const kEndRadians = kStartRadians + 2 * M_PI - 0.01;
And you can eliminate the blue pixels from the tail by tweaking the strokeStart
animation endpoints:
animation.keyPath = @"strokeStart";
animation.fromValue = @1.01f;
animation.toValue = @0.01f;
[ringLayer addAnimation:animation forKey:animation.keyPath];
But you'll still see the tail wiggling:
If you want to do better than that, you can try actually recreating the arrow shape on every frame. I don't know how fast that will be.
Since my other answer (animating two levels of masks) has some graphics glitches, I decided to try redrawing the path on every frame of animation. So first let's write a CALayer
subclass that's like CAShapeLayer
, but just draws an arrow. I originally tried making it a subclass of CAShapeLayer
, but I could not get Core Animation to properly animate it.
Anyway, here's the interface we're going to implement:
@interface ArrowLayer : CALayer
@property (nonatomic) CGFloat thickness;
@property (nonatomic) CGFloat startRadians;
@property (nonatomic) CGFloat lengthRadians;
@property (nonatomic) CGFloat headLengthRadians;
@property (nonatomic, strong) UIColor *fillColor;
@property (nonatomic, strong) UIColor *strokeColor;
@property (nonatomic) CGFloat lineWidth;
@property (nonatomic) CGLineJoin lineJoin;
@end
The startRadians
property is the position (in radians) of the end of the tail. The lengthRadians
is the length (in radians) from the end of the tail to the tip of the arrowhead. The headLengthRadians
is the length (in radians) of the arrowhead.
We also reproduce some of the properties of CAShapeLayer
. We don't need the lineCap
property because we always draw a closed path.
So, how do we implement this crazy thing? As it happens, CALayer will take care of storing any old property you want to define on a subclass. So first, we just tell the compiler not to worry about synthesizing the properties:
@implementation ArrowLayer
@dynamic thickness;
@dynamic startRadians;
@dynamic lengthRadians;
@dynamic headLengthRadians;
@dynamic fillColor;
@dynamic strokeColor;
@dynamic lineWidth;
@dynamic lineJoin;
But we need to tell Core Animation that we need to redraw the layer if any of those properties change. To do that, we need a list of the property names. We'll use the Objective-C runtime to get a list so we don't have to retype the property names. We need to #import <objc/runtime.h>
at the top of the file, and then we can get the list like this:
+ (NSSet *)customPropertyKeys {
static NSMutableSet *set;
static dispatch_once_t once;
dispatch_once(&once, ^{
unsigned int count;
objc_property_t *properties = class_copyPropertyList(self, &count);
set = [[NSMutableSet alloc] initWithCapacity:count];
for (int i = 0; i < count; ++i) {
[set addObject:@(property_getName(properties[i]))];
}
free(properties);
});
return set;
}
Now we can write the method that Core Animation uses to find out which properties need to cause a redraw:
+ (BOOL)needsDisplayForKey:(NSString *)key {
return [[self customPropertyKeys] containsObject:key] || [super needsDisplayForKey:key];
}
It also turns out that Core Animation will make a copy of our layer in every frame of animation. We need to make sure we copy over all of these properties when Core Animation makes the copy:
- (id)initWithLayer:(id)layer {
if (self = [super initWithLayer:layer]) {
for (NSString *key in [self.class customPropertyKeys]) {
[self setValue:[layer valueForKey:key] forKey:key];
}
}
return self;
}
We also need to tell Core Animation that we need to redraw if the layer's bounds change:
- (BOOL)needsDisplayOnBoundsChange {
return YES;
}
Finally, we can get to the nitty-gritty of drawing the arrow. First, we'll change the graphic context's origin to be at the center of the layer's bounds. Then we'll construct the path outlining the arrow (which is now centered at the origin). Finally, we'll fill and/or stroke the path as appropriate.
- (void)drawInContext:(CGContextRef)gc {
[self moveOriginToCenterInContext:gc];
[self addArrowToPathInContext:gc];
[self drawPathOfContext:gc];
}
Moving the origin to the center of our bounds is trivial:
- (void)moveOriginToCenterInContext:(CGContextRef)gc {
CGRect bounds = self.bounds;
CGContextTranslateCTM(gc, CGRectGetMidX(bounds), CGRectGetMidY(bounds));
}
Constructing the arrow path is not trivial. First, we need to get the radial position at which the tail starts, the radial position at which the tail ends and the arrowhead starts, and the radial position of the tip of the arrowhead. We'll use a helper method to compute those three radial positions:
- (void)addArrowToPathInContext:(CGContextRef)gc {
CGFloat startRadians;
CGFloat headRadians;
CGFloat tipRadians;
[self getStartRadians:&startRadians headRadians:&headRadians tipRadians:&tipRadians];
Then we need to figure out the radius of the inside and outside arcs of the arrow, and the radius of the tip:
CGFloat thickness = self.thickness;
CGFloat outerRadius = self.bounds.size.width / 2;
CGFloat tipRadius = outerRadius - thickness / 2;
CGFloat innerRadius = outerRadius - thickness;
We also need to know whether we're drawing the outer arc in a clockwise or counterclockwise direction:
BOOL outerArcIsClockwise = tipRadians > startRadians;
The inner arc will be drawn in the opposite direction.
Finally, we can construct the path. We move to the tip of the arrowhead, then add the two arcs. The CGPathAddArc
call automatically adds a straight line from the path's current point to the starting point of the arc, so we don't need to add any straight lines ourselves:
CGContextMoveToPoint(gc, tipRadius * cosf(tipRadians), tipRadius * sinf(tipRadians));
CGContextAddArc(gc, 0, 0, outerRadius, headRadians, startRadians, outerArcIsClockwise);
CGContextAddArc(gc, 0, 0, innerRadius, startRadians, headRadians, !outerArcIsClockwise);
CGContextClosePath(gc);
}
Now let's figure out how to compute those three radial positions. This would be trivial, except we want to be graceful when the head length is larger than the overall length, by clipping the head length to the overall length. We also want to let the overall length be negative to draw the arrow in the opposite direction. We'll start by picking up the start position, the overall length, and the head length. We'll use a helper that clips the head length to be no larger than the overall length:
- (void)getStartRadians:(CGFloat *)startRadiansOut headRadians:(CGFloat *)headRadiansOut tipRadians:(CGFloat *)tipRadiansOut {
*startRadiansOut = self.startRadians;
CGFloat lengthRadians = self.lengthRadians;
CGFloat headLengthRadians = [self clippedHeadLengthRadians];
Next we compute the radial position where the tail meets the arrowhead. We do so carefully, so that if we clipped the head length, we compute exactly the start position. This is important so that when we call CGPathAddArc
with the two positions, it doesn't add an unexpected arc due to floating-point rounding.
// Compute headRadians carefully so it is exactly equal to startRadians if the head length was clipped.
*headRadiansOut = *startRadiansOut + (lengthRadians - headLengthRadians);
Finally we compute the radial position of the tip of the arrowhead:
*tipRadiansOut = *startRadiansOut + lengthRadians;
}
We need to write the helper that clips the head length. It also needs to ensure that the head length has the same sign as the overall length, so the computations above work correctly:
- (CGFloat)clippedHeadLengthRadians {
CGFloat lengthRadians = self.lengthRadians;
CGFloat headLengthRadians = copysignf(self.headLengthRadians, lengthRadians);
if (fabsf(headLengthRadians) > fabsf(lengthRadians)) {
headLengthRadians = lengthRadians;
}
return headLengthRadians;
}
To draw the path in the graphics context, we need to set the filling and stroking parameters of the context based on our properties, and then call CGContextDrawPath
:
- (void)drawPathOfContext:(CGContextRef)gc {
CGPathDrawingMode mode = 0;
[self setFillPropertiesOfContext:gc andUpdateMode:&mode];
[self setStrokePropertiesOfContext:gc andUpdateMode:&mode];
CGContextDrawPath(gc, mode);
}
We fill the path if we were given a fill color:
- (void)setFillPropertiesOfContext:(CGContextRef)gc andUpdateMode:(CGPathDrawingMode *)modeInOut {
UIColor *fillColor = self.fillColor;
if (fillColor) {
*modeInOut |= kCGPathFill;
CGContextSetFillColorWithColor(gc, fillColor.CGColor);
}
}
We stroke the path if we were given a stroke color and a line width:
- (void)setStrokePropertiesOfContext:(CGContextRef)gc andUpdateMode:(CGPathDrawingMode *)modeInOut {
UIColor *strokeColor = self.strokeColor;
CGFloat lineWidth = self.lineWidth;
if (strokeColor && lineWidth > 0) {
*modeInOut |= kCGPathStroke;
CGContextSetStrokeColorWithColor(gc, strokeColor.CGColor);
CGContextSetLineWidth(gc, lineWidth);
CGContextSetLineJoin(gc, self.lineJoin);
}
}
The end!
@end
So now we can go back to the view controller and use an ArrowLayer
as the image view's mask:
- (void)setUpMask {
arrowLayer = [ArrowLayer layer];
arrowLayer.frame = imageView.bounds;
arrowLayer.thickness = 60;
arrowLayer.startRadians = -M_PI_2;
arrowLayer.lengthRadians = 0;
arrowLayer.headLengthRadians = M_PI_2 / 8;
arrowLayer.fillColor = [UIColor whiteColor];
imageView.layer.mask = arrowLayer;
}
And we can just animate the lengthRadians
property from 0 to 2 π:
- (IBAction)goButtonWasTapped:(UIButton *)goButton {
goButton.hidden = YES;
[CATransaction begin]; {
[CATransaction setAnimationDuration:2];
[CATransaction setCompletionBlock:^{
goButton.hidden = NO;
}];
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"lengthRadians"];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.autoreverses = YES;
animation.fromValue = @0.0f;
animation.toValue = @((CGFloat)(2.0f * M_PI));
[arrowLayer addAnimation:animation forKey:animation.keyPath];
} [CATransaction commit];
}
and we get a glitch-free animation:
I profiled this on my iPhone 4S running iOS 6.0.1 using the Core Animation instrument. It seems to get 40-50 frames per second. Your mileage may vary. I tried turning on the drawsAsynchronously
property (new in iOS 6) but it didn't make a difference.
I've uploaded the code in this answer as a gist for easy copying.