动画Core Animation中圆形箭头蒙版的长度

时间:2012-11-26 09:42:23

标签: objective-c ios core-animation caanimation

我使用CAShapeLayer和masking创建了一个圆形动画。 这是我的代码:

- (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"];


}

这是我的背景图片: enter image description here

这是我运行动画时的样子: enter image description here

但是我想要的是,箭头缺少如何添加这个? enter image description here

3 个答案:

答案 0 :(得分:17)

由于my other answer (animating two levels of masks)有一些图形故障,我决定尝试重绘每一帧动画的路径。首先让我们编写一个类似CALayer的{​​{1}}子类,但只是绘制一个箭头。我最初尝试将其作为CAShapeLayer的子类,但我无法让Core Animation正确地为其设置动画。

无论如何,这是我们要实现的界面:

CAShapeLayer

@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 属性是尾部末尾的位置(以弧度表示)。 startRadians是从尾部末端到箭头尖端的长度(以弧度表示)。 lengthRadians是箭头的长度(以弧度表示)。

我们还会重现headLengthRadians的一些属性。我们不需要CAShapeLayer属性,因为我们总是绘制一个封闭的路径。

那么,我们如何实现这个疯狂的事情呢?碰巧,CALayer will take care of storing any old property you want to define on a subclass。首先,我们告诉编译器不要担心合成属性:

lineCap

但我们需要告诉Core Animation,如果这些属性发生任何变化,我们需要重新绘制图层。为此,我们需要一个属性名称列表。我们将使用Objective-C运行时获取列表,因此我们不必重新键入属性名称。我们需要在文件顶部@implementation ArrowLayer @dynamic thickness; @dynamic startRadians; @dynamic lengthRadians; @dynamic headLengthRadians; @dynamic fillColor; @dynamic strokeColor; @dynamic lineWidth; @dynamic lineJoin; ,然后我们可以得到如下列表:

#import <objc/runtime.h>

现在我们可以编写Core Animation用来找出导致重绘的属性的方法:

+ (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;
}

事实证明,Core Animation将在每一帧动画中制作我们图层的副本。我们需要确保在Core Animation制作副本时复制所有这些属性:

+ (BOOL)needsDisplayForKey:(NSString *)key {
    return [[self customPropertyKeys] containsObject:key] || [super needsDisplayForKey:key];
}

我们还需要告诉Core Animation,如果图层的边界发生变化,我们需要重绘:

- (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;
}

最后,我们可以了解绘制箭头的细节。首先,我们将图形上下文的原点更改为图层边界的中心。然后我们将构建概述箭头的路径(现在以原点为中心)。最后,我们将适当地填充和/或描述路径。

- (BOOL)needsDisplayOnBoundsChange {
    return YES;
}

将原点移动到我们边界的中心是微不足道的:

- (void)drawInContext:(CGContextRef)gc {
    [self moveOriginToCenterInContext:gc];
    [self addArrowToPathInContext:gc];
    [self drawPathOfContext:gc];
}

构建箭头路径并非易事。首先,我们需要获得尾部开始的径向位置,尾部和箭头开始的径向位置,以及箭头尖端的径向位置。我们将使用辅助方法来计算这三个径向位​​置:

- (void)moveOriginToCenterInContext:(CGContextRef)gc {
    CGRect bounds = self.bounds;
    CGContextTranslateCTM(gc, CGRectGetMidX(bounds), CGRectGetMidY(bounds));
}

然后我们需要弄清楚箭头内外圆弧的半径和尖端的半径:

- (void)addArrowToPathInContext:(CGContextRef)gc {
    CGFloat startRadians;
    CGFloat headRadians;
    CGFloat tipRadians;
    [self getStartRadians:&startRadians headRadians:&headRadians tipRadians:&tipRadians];

我们还需要知道我们是以顺时针还是逆时针方向绘制外弧:

    CGFloat thickness = self.thickness;

    CGFloat outerRadius = self.bounds.size.width / 2;
    CGFloat tipRadius = outerRadius - thickness / 2;
    CGFloat innerRadius = outerRadius - thickness;

内弧将以相反的方向绘制。

最后,我们可以构建路径。我们移动到箭头的尖端,然后添加两个弧。 BOOL outerArcIsClockwise = tipRadians > startRadians; 调用会自动从路径的当前点添加直线到弧的起点,因此我们不需要自己添加任何直线:

CGPathAddArc

现在让我们弄清楚如何计算这三个径向位​​置。这将是微不足道的,除非我们希望在头部长度大于总长度时通过将头部长度剪切到总长度来优雅。我们还想让总长度为负,以向相反方向绘制箭头。我们首先选择起始位置,总长度和头部长度。我们将使用一个帮助器,将头部长度剪切为不大于总长度:

    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);
}

接下来,我们计算尾部与箭头相交的径向位置。我们小心翼翼地这样做,这样如果我们剪掉头部长度,我们就会准确计算起始位置。这很重要,因此当我们用两个位置调用- (void)getStartRadians:(CGFloat *)startRadiansOut headRadians:(CGFloat *)headRadiansOut tipRadians:(CGFloat *)tipRadiansOut { *startRadiansOut = self.startRadians; CGFloat lengthRadians = self.lengthRadians; CGFloat headLengthRadians = [self clippedHeadLengthRadians]; 时,由于浮点舍入,它不会添加意外的弧。

CGPathAddArc

最后我们计算出箭头尖端的径向位置:

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

我们需要编写剪辑头长的帮助器。它还需要确保头部长度与总长度具有相同的符号,因此上面的计算可以正常工作:

    *tipRadiansOut = *startRadiansOut + lengthRadians;
}

要在图形上下文中绘制路径,我们需要根据属性设置上下文的填充和描边参数,然后调用- (CGFloat)clippedHeadLengthRadians { CGFloat lengthRadians = self.lengthRadians; CGFloat headLengthRadians = copysignf(self.headLengthRadians, lengthRadians); if (fabsf(headLengthRadians) > fabsf(lengthRadians)) { headLengthRadians = lengthRadians; } return headLengthRadians; }

CGContextDrawPath

如果给出填充颜色,我们填写路径:

- (void)drawPathOfContext:(CGContextRef)gc {
    CGPathDrawingMode mode = 0;
    [self setFillPropertiesOfContext:gc andUpdateMode:&mode];
    [self setStrokePropertiesOfContext:gc andUpdateMode:&mode];

    CGContextDrawPath(gc, mode);
}

如果给出笔划颜色和线宽,我们会对路径进行描边:

- (void)setFillPropertiesOfContext:(CGContextRef)gc andUpdateMode:(CGPathDrawingMode *)modeInOut {
    UIColor *fillColor = self.fillColor;
    if (fillColor) {
        *modeInOut |= kCGPathFill;
        CGContextSetFillColorWithColor(gc, fillColor.CGColor);
    }
}

结束!

- (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);
    }
}

现在我们可以回到视图控制器并使用@end 作为图像视图的掩码:

ArrowLayer

我们可以将- (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; } 属性设置为从0到2π的动画:

lengthRadians

我们得到了一个无故障的动画:

arrow animation with no glitches

我使用Core Animation乐器在运行iOS 6.0.1的iPhone 4S上对此进行了分析。它似乎每秒可以获得40-50帧。你的旅费可能会改变。我尝试打开- (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]; } 属性(iOS 6中的新属性),但它没有什么区别。

我已将此答案中的代码上传为a gist for easy copying

答案 1 :(得分:4)

更新

See my other answer找到一个没有故障的解决方案。

ORIGINAL

这是一个有趣的小问题。我不认为我们可以用Core Animation完美地解决它,但我们可以做得很好。

我们应该在布局视图时设置遮罩,因此我们只需在首次出现图像视图或更改大小时进行遮罩。所以,让我们来自viewDidLayoutSubviews

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    [self setUpMask];
}

- (void)setUpMask {
    arrowLayer = [self arrowLayerWithFrame:imageView.bounds];
    imageView.layer.mask = arrowLayer;
}

此处,arrowLayer是一个实例变量,因此我可以为图层设置动画。

要实际创建箭头形图层,我需要一些常量:

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;

现在我可以创建图层了。由于没有“箭头形”线端盖,我必须制作一条概述整条路径的路径,包括尖尖端:

- (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;
}

如果我们这样做,它看起来像这样:

full arrow

现在,我们希望箭头四处移动,因此我们将旋转动画应用于蒙版:

- (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];
}

当我们点击Go按钮时,它看起来像这样:

rotating unclipped arrow

当然,那是不对的。我们需要剪掉箭尾。为此,我们需要在蒙版上应用蒙版。我们无法直接应用它(我尝试过)。相反,我们需要一个额外的图层作为图像视图的蒙版。层次结构如下所示:

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)

新的环形图层就像您最初绘制蒙版的尝试一样:单个描边的ARC段。我们将通过重写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;
}

我们现在有另一个ivar,ringLayer,因为我们也需要为它制作动画。 arrowLayerWithFrame:方法保持不变。以下是我们创建环形层的方法:

- (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;
}

请注意,我们将strokeStart设置为1,而不是将strokeEnd设置为0.行程末端位于箭头的尖端,我们始终希望提示是可见的,所以我们不管它。

最后,我们重写goButtonWasTapped以设置环形图层strokeStart的动画(除了为箭头图层的旋转设置动画):

- (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];
}

最终结果如下:

rotating clipped arrow

它还不完美。尾巴有点摆动,有时候你会看到一列蓝色像素。在尖端,你有时也会得到一条白线的低语。我认为这是由于Core Animation在内部表示弧的方式(作为一个三次Bezier样条)。它无法完美地测量strokeStart沿路径的距离,因此它近似,有时近似值足以泄漏一些像素。您可以通过将kEndRadians更改为此来修复提示问题:

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

你可以通过调整strokeStart动画端点来消除尾部的蓝色像素:

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

但是你仍然会看到尾巴摇摆不定:

rotating clipped arrow with tweaks

如果你想做得更好,你可以尝试在每一帧上重新创建箭头形状。我不知道会有多快。

答案 2 :(得分:0)

不幸的是,路径绘图中没有选项可以像您描述的那样使用尖头线条(使用CAShapeLayer的{​​{1}}属性可以选择选项,而不是您需要的选项)。

您必须自己绘制路径边界并填充它,而不是依赖于笔划的宽度。这意味着3条线和2条弧,这应该是可管理的,尽管不像你试图做的那么简单。