Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

animating the length of circular arrow mask in Core Animation

I've 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's layer.
    imageView.layer.mask = maskLayer;

    //Set our mask layer's frame to the parent layer's 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

This what is looks like when I run the animation: enter image description here

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

like image 880
aahsanali Avatar asked Nov 30 '22 13:11

aahsanali


1 Answers

UPDATE

See my other answer for a solution that doesn't have glitches.

ORIGINAL

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:

full arrow

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:

rotating unclipped arrow

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:

rotating clipped arrow

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:

rotating clipped arrow with tweaks

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.

like image 153
rob mayoff Avatar answered Dec 21 '22 05:12

rob mayoff