私のもう 1 つの回答 (2 レベルのマスクのアニメーション化)にはグラフィックスの不具合があるため、アニメーションのすべてのフレームでパスを再描画することにしました。最初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
The startRadians
property is the position (in radians) of the end of the tail. The lengthRadians
is the length (in radians) from the end of the tail to the tip of the arrowhead. The headLengthRadians
is the length (in radians) of the arrowhead.
のプロパティの一部も再現します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));
}
矢印パスの構築は簡単ではありません。まず、尾が始まる半径位置、尾が終わり、矢じりが始まる半径位置、および矢じりの先端の半径位置を取得する必要があります。ヘルパー メソッドを使用して、これら 3 つの半径位置を計算します。
- (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;
内側の円弧は反対方向に描画されます。
最後に、パスを構築できます。矢印の先端に移動し、2 つの円弧を追加します。この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);
}
それでは、これら 3 つの半径位置を計算する方法を考えてみましょう。これは些細なことですが、頭の長さが全体の長さよりも大きい場合に、頭の長さを全体の長さにクリップすることによって優雅にしたい場合を除きます。また、矢印を反対方向に描画するために、全長を負にしたいと考えています。開始位置、全長、ヘッドの長さをピックアップします。頭の長さを全体の長さよりも大きくならないようにクリップするヘルパーを使用します。
- (void)getStartRadians:(CGFloat *)startRadiansOut headRadians:(CGFloat *)headRadiansOut tipRadians:(CGFloat *)tipRadiansOut {
*startRadiansOut = self.startRadians;
CGFloat lengthRadians = self.lengthRadians;
CGFloat headLengthRadians = [self clippedHeadLengthRadians];
次に、尾部が矢じりと交わる半径方向の位置を計算します。頭の長さを切り取った場合は、開始位置を正確に計算できるように、慎重に行います。これは、2 つの位置で呼び出したときにCGPathAddArc
、浮動小数点の丸めによって予期しない円弧が追加されないようにするために重要です。
// Compute headRadians carefully so it is exactly equal to startRadians if the head length was clipped.
*headRadiansOut = *startRadiansOut + (lengthRadians - headLengthRadians);
Finally we compute the radial position of the tip of the arrowhead:
*tipRadiansOut = *startRadiansOut + lengthRadians;
}
We need to write the helper that clips the head length. It also needs to ensure that the head length has the same sign as the overall length, so the computations above 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 the path in the graphics context, we need to set the filling and stroking parameters of 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 were 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 were given a stroke color and a 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 an ArrowLayer
as the image view's 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 a glitch-free animation:
Core Animation インストゥルメントを使用して、iOS 6.0.1 を実行している iPhone 4S でこれをプロファイリングしました。毎秒 40 ~ 50 フレームを取得するようです。あなたのマイレージは異なる場合があります。プロパティ (iOS 6の新機能) をオンにしてみましたdrawsAsynchronously
が、違いはありませんでした。
簡単にコピーできるように、この回答のコードを要点としてアップロードしました。