Since my other answer (animation of two levels of masks) has some graphic glitches, I decided to try to redraw the path on each frame of the animation. So first write a subclass of CALayer that CAShapeLayer likes, but just draws an arrow. I initially tried to make it a subclass of CAShapeLayer , but I could not get Core Animation to animate it correctly.
In any case, here is the interface that we are 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 tail end. lengthRadians - the length (in radians) from the end of the tail to the tip of the arrowhead. headLengthRadians - the length (in radians) of the arrow.
We also reproduce some properties of CAShapeLayer . We do not need the lineCap property, because we always make a closed path.
So how do we implement this crazy thing? As this happens, CALayer will take care of preserving any old property that you want to define in a subclass . So, first we just tell the compiler not to worry about synthesizing properties:
@implementation ArrowLayer @dynamic thickness; @dynamic startRadians; @dynamic lengthRadians; @dynamic headLengthRadians; @dynamic fillColor; @dynamic strokeColor; @dynamic lineWidth; @dynamic lineJoin;
But we have to tell Core Animation that we need to redraw the layer if any of these properties change. To do this, we need a list of property names. We will use the Objective-C runtime to get the list, so we don’t need to re-specify property names. We need #import <objc/runtime.h> at the top of the file, and then we can get the list as follows:
+ (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 a method that uses Core Animation to figure out which properties should trigger 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 each frame of the animation. We need to make sure that we copy all these properties when Core Animation makes a 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 should redraw if layer changes change:
- (BOOL)needsDisplayOnBoundsChange { return YES; }
Finally, we can reach out to drawing the arrow. First, we will change the beginning of the graphics context to be at the center of the layer’s borders. Then we construct a path denoting the arrow (which is now centered at the origin). Finally, we will place and / or place the path as necessary.
- (void)drawInContext:(CGContextRef)gc { [self moveOriginToCenterInContext:gc]; [self addArrowToPathInContext:gc]; [self drawPathOfContext:gc]; }
Moving the origin to the center of our borders is trivial:
- (void)moveOriginToCenterInContext:(CGContextRef)gc { CGRect bounds = self.bounds; CGContextTranslateCTM(gc, CGRectGetMidX(bounds), CGRectGetMidY(bounds)); }
Building the arrow path is not trivial. First, we need to get the radial position at which the tail begins, the radial position at which the tail and the beginning of the arrow end, and the radial position of the arrowhead tip. We will use an auxiliary method to calculate these three radial positions:
- (void)addArrowToPathInContext:(CGContextRef)gc { CGFloat startRadians; CGFloat headRadians; CGFloat tipRadians; [self getStartRadians:&startRadians headRadians:&headRadians tipRadians:&tipRadians];
Then we need to find out the radius of the inner and outer 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 if we are drawing the outer arc clockwise or counterclockwise:
BOOL outerArcIsClockwise = tipRadians > startRadians;
The inner arc will be facing in the opposite direction.
Finally, we can build a path. Go to the tip of the arrowhead, then add two arcs. Calling CGPathAddArc automatically adds a straight line from the current waypoint to the starting point of the arc, so we do not need to add straight lines:
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 find out how to calculate these three radial positions. This would be trivial, except that we want to be graceful when the length of the head is greater than the total length, cutting off the length of the head to the total length. We also want the total length to be negative in order to draw the arrow in the opposite direction. Let's start by taking the starting position, the total length and length of the head. We will use an assistant that fixes the length of the head no more than the total length:
- (void)getStartRadians:(CGFloat *)startRadiansOut headRadians:(CGFloat *)headRadiansOut tipRadians:(CGFloat *)tipRadiansOut { *startRadiansOut = self.startRadians; CGFloat lengthRadians = self.lengthRadians; CGFloat headLengthRadians = [self clippedHeadLengthRadians];
Then we calculate the radial position where the tail meets the arrowhead. We do this carefully, so if we cut the length of the head, we will accurately calculate the starting position. This is important, so when we call CGPathAddArc with two positions, it does not add an unexpected arc due to floating point rounding.
Finally, we calculate the radial position of the arrowhead tip:
*tipRadiansOut = *startRadiansOut + lengthRadians; }
We need to write an assistant who will copy the length of the head. It is also necessary to ensure that the length of the head has the same sign as the total length, so the above calculations 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 a path in a graphical context, we need to set the parameters for filling and stroking 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 are 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 are given the color of the stroke and the 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 ArrowLayer as the image 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 the animation without glitches:

I profiled this on my iPhone 4S running iOS 6.0.1 using the Core Animation tool. It seems to get 40-50 frames per second. Your mileage may vary. I tried to enable the drawsAsynchronously property (new in iOS 6), but that did not affect.
I have uploaded the code in this answer both for ease of copying .