由于我的另一个答案(动画两个级别的蒙版)有一些图形故障,我决定尝试在每一帧动画上重绘路径。所以首先让我们编写一个类似于 的CALayer
子类CAShapeLayer
,但只画一个箭头。我最初尝试将其设为 的子类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
将负责存储您要在子类上定义的任何旧属性。所以首先,我们只是告诉编译器不要担心合成属性:
@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];
}
我们得到一个无故障的动画:
我使用 Core Animation 工具在运行 iOS 6.0.1 的 iPhone 4S 上对此进行了分析。它似乎每秒获得 40-50 帧。你的旅费可能会改变。我尝试打开该drawsAsynchronously
属性(iOS 6 中的新功能),但没有任何区别。
我已经上传了这个答案中的代码作为便于复制的要点。