Animation of the length of a circular arrow mask in Core Animation

I created a circular animation using CAShapeLayer and masking. Here is my code:

- (void) maskAnimation{ animationCompletionBlock theBlock; imageView.hidden = FALSE;//Show the image view CAShapeLayer *maskLayer = [CAShapeLayer layer]; CGFloat maskHeight = imageView.layer.bounds.size.height; CGFloat maskWidth = imageView.layer.bounds.size.width; CGPoint centerPoint; centerPoint = CGPointMake( maskWidth/2, maskHeight/2); //Make the radius of our arc large enough to reach into the corners of the image view. CGFloat radius = sqrtf(maskWidth * maskWidth + maskHeight * maskHeight)/2; //Don't fill the path, but stroke it in black. maskLayer.fillColor = [[UIColor clearColor] CGColor]; maskLayer.strokeColor = [[UIColor blackColor] CGColor]; maskLayer.lineWidth = 60; CGMutablePathRef arcPath = CGPathCreateMutable(); //Move to the starting point of the arc so there is no initial line connecting to the arc CGPathMoveToPoint(arcPath, nil, centerPoint.x, centerPoint.y-radius/2); //Create an arc at 1/2 our circle radius, with a line thickess of the full circle radius CGPathAddArc(arcPath, nil, centerPoint.x, centerPoint.y, radius/2, 3*M_PI/2, -M_PI/2, NO); maskLayer.path = arcPath;//[aPath CGPath];//arcPath; //Start with an empty mask path (draw 0% of the arc) maskLayer.strokeEnd = 0.0; CFRelease(arcPath); //Install the mask layer into out image view layer. imageView.layer.mask = maskLayer; //Set our mask layer frame to the parent layer bounds. imageView.layer.mask.frame = imageView.layer.bounds; //Create an animation that increases the stroke length to 1, then reverses it back to zero. CABasicAnimation *swipe = [CABasicAnimation animationWithKeyPath:@"strokeEnd"]; swipe.duration = 5; swipe.delegate = self; [swipe setValue: theBlock forKey: kAnimationCompletionBlock]; swipe.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]; swipe.fillMode = kCAFillModeForwards; swipe.removedOnCompletion = NO; swipe.autoreverses = YES; swipe.toValue = [NSNumber numberWithFloat: 1.0]; [maskLayer addAnimation: swipe forKey: @"strokeEnd"]; } 

This is my background image: enter image description here

It looks like as soon as I start the animation: enter image description here

But what I want, the arrow is not enough, how to add this? enter image description here

+4
source share
3 answers

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.

  // Compute headRadians carefully so it is exactly equal to startRadians if the head length was clipped. *headRadiansOut = *startRadiansOut + (lengthRadians - headLengthRadians); 

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:

arrow animation with no 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 .

+17
source

UPDATE

See my other answer for a solution that has no glitches.

ORIGINAL

This is an interesting little problem. I don’t think we can solve this with Core Animation, but we can do it well.

We need to adjust the mask when we lay out the view, so we need to do this only when we first present the image or change the 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 create an arrow 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 a layer. Since there is no “arrow” line end cap, I have to make a path that describes the whole path, including the pointed 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 all do this, it looks like this:

full arrow

Now we want the arrow to go through, so we apply the 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 press the Go button, it looks like this:

rotating unclipped arrow

This is not correct, of course. We need to fix the tail of the arrow. To do this, we need to apply the mask to the mask. We cannot apply it directly (I tried). Instead, we need an extra layer to act as an image view mask. The hierarchy is as follows:

 Image view layer Mask layer (just a generic `CALayer` set as the image view layer 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 similar to your original attempt to draw a mask: one stroked ARC segment. We will create a 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; } 

Now we have another ivar, ringLayer , because we also need to animate it. The arrowLayerWithFrame: method arrowLayerWithFrame: not change. Here we create an annular 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; } 

Notice that we set strokeStart to 1 instead of setting strokeEnd to 0. The length of the stroke 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 of strokeStart (in addition to animating rotation of the arrow layer):

 - (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 is as follows:

rotating clipped arrow

This is still not perfect. It sways a little on the tail, and sometimes you get a column of blue pixels. 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 inner arc (like a cubic Bezier spline). It cannot accurately measure the distance along the path for strokeStart , so it gets close, and sometimes the approximation is turned off enough for some pixels to leak. You can fix the tip problem by changing kEndRadians to this:

 static CGFloat const kEndRadians = kStartRadians + 2 * M_PI - 0.01; 

And you can exclude the blue pixels from the tail by editing the end points of the strokeStart animation:

  animation.keyPath = @"strokeStart"; animation.fromValue = @1.01f; animation.toValue = @0.01f; [ringLayer addAnimation:animation forKey:animation.keyPath]; 

But you will still see the tail moving:

rotating clipped arrow with tweaks

If you want to do it better, you can try to actually recreate the shape of the arrow on each frame. I don’t know how fast it will be.

+4
source

Unfortunately, there are no options in drawing paths to have a pointed straight line similar to the one you are describing (parameters are available using the CAShapeLayer lineCap , not the one you need).

You will have to draw the border of the path yourself and fill it out, instead of relying on the stroke width. This means that there are 3 lines and 2 arcs that should be controllable, although not as straight as what you were trying to do.

0
source

All Articles