在 Core Animation 中为圆形箭头遮罩的长度设置动画
Posted
技术标签:
【中文标题】在 Core Animation 中为圆形箭头遮罩的长度设置动画【英文标题】:animating the length of circular arrow mask in Core Animation 【发布时间】:2012-11-26 09:42:22 【问题描述】:我使用 CAShapeLayer 和遮罩创建了一个圆形动画。 这是我的代码:
- (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"];
这是我的背景图片:
这是我运行动画时的样子:
但是我想要的,箭头不见了怎么加这个?
【问题讨论】:
您可以创建一个带有两个子层的遮罩层(一个是代码的 strokeEnd 动画形状层,一个是沿相同圆形路径为其位置设置动画的三角形)。不过我没试过。 【参考方案1】:由于my other answer (animating two levels of masks) 有一些图形故障,我决定尝试在动画的每一帧上重新绘制路径。所以首先让我们编写一个类似于CAShapeLayer
的CALayer
子类,但只是画一个箭头。我最初尝试将其设为 CAShapeLayer
的子类,但我无法让 Core Animation 正确地为其设置动画。
不管怎样,这是我们要实现的接口:
@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
的一些属性。我们不需要lineCap
属性,因为我们总是绘制闭合路径。
那么,我们如何实现这个疯狂的东西呢?碰巧,CALayer
will take care of storing any old property you want to define on a subclass。所以首先,我们只是告诉编译器不要担心合成属性:
@implementation ArrowLayer
@dynamic thickness;
@dynamic startRadians;
@dynamic lengthRadians;
@dynamic headLengthRadians;
@dynamic fillColor;
@dynamic strokeColor;
@dynamic lineWidth;
@dynamic lineJoin;
但是我们需要告诉 Core Animation,如果这些属性中的任何一个发生变化,我们需要重新绘制图层。为此,我们需要一个属性名称列表。我们将使用 Objective-C 运行时来获取一个列表,因此我们不必重新键入属性名称。我们需要在文件顶部#import <objc/runtime.h>
,然后我们可以得到这样的列表:
+ (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 使用的方法来找出哪些属性需要导致重绘:
+ (BOOL)needsDisplayForKey:(NSString *)key
return [[self customPropertyKeys] containsObject:key] || [super needsDisplayForKey:key];
事实证明,Core Animation 会在动画的每一帧中复制我们的图层。当 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;
我们还需要告诉 Core Animation,如果层的边界发生变化,我们需要重绘:
- (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;
我们可以将lengthRadians
属性从 0 设置为 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];
我们得到一个无故障的动画:
我在运行 ios 6.0.1 的 iPhone 4S 上使用 Core Animation 工具对此进行了分析。它似乎每秒获得 40-50 帧。你的旅费可能会改变。我尝试打开drawsAsynchronously
属性(iOS 6 中的新属性),但没有任何作用。
我已将此答案中的代码上传为a gist for easy copying。
【讨论】:
但是有一个小问题,当我将应用程序放在后台并再次将其带到前面时,图层消失了:( 当应用程序进入后台时动画被移除。在我的示例代码中,我将层初始化为lengthRadians
设置为零。当动画被移除时,这就是图层被绘制的值。尝试将其设置为其他内容或在应用进入前台时再次添加动画。
动画长度为负数。
我想要整个动画反转,包括箭头。换句话说,我想翻转动画(从左上角开始动画并逆时针动画到右上角)
我敢肯定,如果你和toValue
和fromValue
一起玩,你会找到办法的。【参考方案2】:
更新
See my other answer 表示没有故障的解决方案。
原创
这是一个有趣的小问题。我不认为我们可以仅使用 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;
如果我们这样做,它看起来像这样:
现在,我们想让箭头四处走动,所以我们对遮罩应用旋转动画:
- (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 按钮时,它看起来像这样:
当然,这是不对的。我们需要剪掉箭头的尾巴。为此,我们需要对蒙版应用蒙版。我们不能直接应用它(我试过)。相反,我们需要一个额外的层来充当图像视图的蒙版。层次结构如下所示:
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];
最终结果如下所示:
它仍然不完美。尾部有一点摆动,有时你会在那里看到一列蓝色像素。在尖端,您有时还会听到一条白线的耳语。我认为这是由于核心动画在内部表示弧的方式(作为三次贝塞尔样条曲线)。它不能完美地测量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];
但你仍然会看到尾巴摆动:
如果您想做得更好,您可以尝试在每一帧上实际重新创建箭头形状。不知道会有多快。
【讨论】:
【参考方案3】:不幸的是,路径绘图中没有选项可以像您描述的那样具有尖线帽(使用CAShapeLayer
的lineCap
属性可以获得选项,而不是您需要的那个)。
您必须自己绘制路径边界并填充它,而不是依赖笔画的宽度。这意味着 3 条线和 2 条弧线,应该是易于管理的,尽管不像您尝试做的那样简单。
【讨论】:
以上是关于在 Core Animation 中为圆形箭头遮罩的长度设置动画的主要内容,如果未能解决你的问题,请参考以下文章