複数の実行時間と種類の異なるCAAnimationをチェーンさせたい。
1秒かけてx方向に300px、2秒かけてy方向に100px、1秒かけて45度回転...というような感じに。
ですが、CAAnimationは非同期処理なので、原則としてあるアニメーションが終わったら次、みたいなことはできません。
CFRunLoopによるCore Animationの逐次的アニメーション
http://qiita.com/icecocoa6/items/6d5c023ada5e30eb209c
↑のTIPSはCFRunLoopを使ってカレントループをアニメーションが終わるまでブロックするという方法で、これでもいいようなきがしていたのですが、一つ大きな問題があって、それはリンク先の追記にも書いてあるとおり、
CFRunLoopは、入力ソースなどのイベントを監視するためのクラス。CFRunLoopRun()を実行すると監視ループに入り、現在のスレッドでの処理が止まる。
CFRunLoopは根本的にユーザの操作を待つための機構であり、非同期処理を同期処理に変えるためのものではないということです。
とはいえ固いことは言わずにやってみるといい感じに動いたので、これでいいかなと思っていたら、思わぬ問題が。
CFRunLoopが抜けてしまう
どうもCocoaだけの問題みたいなのですが、 CFRunLoopでカレントループをブロックしている最中にマウスを動かしたりキーを押したりすると、現在固めている処理が飛ばされてメインスレッドの処理に戻るようです。
こんなコードを書きました。
三角形を描くアニメーションです。
- (IBAction)button:(id)sender {
CGPoint p = layer.position;
p.x += 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim1"];
p.y += 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim2"];
p.x -= 200;
p.y -= 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim3"];
}
- (void)enqueueAnimationForKeyPath:(NSString*)keyPath fromValue:(id)fromValue toValue:(id)toValue duration:(NSTimeInterval)duration animationKey:(NSString*)animationKey;
{
CABasicAnimation *a = [CABasicAnimation animation];
a.duration = duration;
a.keyPath = keyPath;
a.removedOnCompletion = NO;
a.fillMode = kCAFillModeForwards;
a.fromValue = fromValue;
a.toValue = toValue;
[layer setValue:a.toValue forKey:keyPath];
[CATransaction begin];
CFRunLoopRef rl = CFRunLoopGetCurrent();
CFDateRef distantFuture = (__bridge CFDateRef)[NSDate distantFuture];
CFRunLoopTimerRef timer = CFRunLoopTimerCreate(NULL, CFDateGetAbsoluteTime(distantFuture), 0, 0, 0, NULL, NULL);
CFRunLoopAddTimer(rl, timer, kCFRunLoopDefaultMode);
[CATransaction setCompletionBlock:^{
CFRunLoopStop(rl);
NSLog(@"%@ was completed",animationKey);
}];
[layer addAnimation:a forKey:animationKey];
[CATransaction commit];
CFRunLoopRun();
CFRelease(timer);
CFRunLoopTimerInvalidate(timer);
}
↓こんな感じになります。
CFRunLoop breaking from keroxp on Vimeo.
最初の二回はボタンを押したあと何もせずに待っていますが、次からボタンを押したあとにマウスを動かしたりクリックしてみると、実行途中のアニメーションがブレイクされて最後の斜め左下へのアニメーションが実行されてしまいます。
理由はイマイチよく分からないのですが、おそらくユーザの操作をNSResponderがキャッチした瞬間にCFRunLoopが強制的に抜けてしまうようです。動画を見ると分かるように、CATransactionのcompletionBlockは遅れて実行されています。
とにもかくにも、これではちょっと使えません。
NSRunLoopを使う
Cocoaに存在する禁断の同期処理方法、それがNSRunLoop。
メインスレッドを処理を規定の時間止めてしまうという恐ろしい方法です。
ハイ、そこでこんなコードを書きました。
- (void)enqueueAnimationForKeyPath:(NSString*)keyPath fromValue:(id)fromValue toValue:(id)toValue duration:(NSTimeInterval)duration animationKey:(NSString*)animationKey;
{
CABasicAnimation *a = [CABasicAnimation animation];
a.duration = duration;
a.keyPath = keyPath;
a.removedOnCompletion = NO;
a.fillMode = kCAFillModeForwards;
a.fromValue = fromValue;
a.toValue = toValue;
[layer setValue:a.toValue forKey:keyPath];
__block BOOL finished = NO;
static double intvl = (double)1/1000000;
[CATransaction begin];
[CATransaction setCompletionBlock:^{
finished = YES;
}];
[layer addAnimation:animation forKey:animationKey];
[CATransaction commit];
// 100万分の1秒に一度処理を戻してアニメーションが終わっているか確認する
while (!finished) {
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:intvl]];
}
}
一応ちゃんと動きます。動きますが……
**NSRunLoopはCFRunLoopとは微妙に違うので、このコードを実行すると本当にメインスレッドのすべての処理が止まります。**つまり、Macだと虹色のぐるぐるが出ます。
アニメーションの実行中は他のUI操作が全くできなくなるので論外です。
NSOperationを使う
本日のFAです。
上記の二つのRunLoopを試して途方にくれていた私がひらめいたひとつの方法。
それがNSOperationを使う方法です。
NSOperationはサブクラスとして定義することで、独自の処理をOperationを非同期/同期実行することが出来るようになります。また、GCDにない大きなメリットとして、addDependency:メソッドを使うことで非同期処理の順次実行を保証することができるうえ、[NSOpeationQueue mainQueue]に投げることでスレッドセーフに実行できます。
ハイ、そこでこんなクラスを作りました。
@interface SEAnimationOperation : NSOperation
+ (instancetype)animationOperationWithTarget:(CALayer*)target animation:(CAAnimation*)animation;
@property (nonatomic, weak) CALayer *target;
@property (nonatomic) CAAnimation *animation;
@end
@implementation SEAnimationOperation
{
BOOL isFinished;
BOOL isExecuting;
}
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
{
if ([key isEqualToString:@"isFinished"]
|| [key isEqualToString:@"isExecuting"]) {
return YES;
}
return [super automaticallyNotifiesObserversForKey:key];
}
+ (instancetype)animationOperationWithTarget:(CALayer *)target animation:(CAAnimation *)animation
{
return [[self alloc] initWithTarget:target animation:animation];
}
- (id)initWithTarget:(CALayer*)target animation:(CAAnimation *)animation
{
self = [self init];
_target = target;
_animation = animation;
return self ?: nil;
}
- (BOOL)isConcurrent
{
return YES;
}
- (BOOL)isFinished
{
return isFinished;
}
- (BOOL)isExecuting
{
return isExecuting;
}
- (void)start
{
isFinished = NO;
isExecuting = YES;
[CATransaction begin];
__block __weak typeof (self) __self = self;
[CATransaction setCompletionBlock:^{
[__self setValue:@YES forKey:@"isFinished"];
[__self setValue:@NO forKey:@"isExecuting"];
}];
[self.target addAnimation:self.animation forKey:nil];
[CATransaction commit];
}
@end
こんな感じで使います。
- (void)hoge
{
// self.layerがあるとして...
CAAnimation *a1 = // ...
CAAnimation *a2 = // ...
CAAnimation *a3 = // ...
SEAnimationOperation *op1 = [SEAnimationOperation animationOperationWithTarget:self.layer animation:a1];
SEAnimationOperation *op2 = [SEAnimationOperation animationOperationWithTarget:self.layer animation:a2];
SEAnimationOperation *op3 = [SEAnimationOperation animationOperationWithTarget:self.layer animation:a3];
[op2 addDependency:op1];
[op3 addDependency:op2];
// a1,a2,a3が順番に実行される
[NSOperationQueue mainQueue] addOperations:@[op1,op2,op3] waitUntilFinsihed:NO];
}
便利ですね。
※2014/02/28 21:48追記
※2014/03/01 19:05追記
サブスレッドでCoreAnimationを行うと、描画更新の関係で色々問題があるので以下の方法はやらないほうがいいです!!
そもそもCoreAnimationは別スレッドで実行可能だった
アニメーションの実行をサブスレッドに飛ばせばCFRunLoopでカレントループを固めてもメインスレッドでないので上記のような問題は起こらないことを確認しました。安全性はまだ分かりませんが、これでいい気がします。
- (IBAction)button:(id)sender {
// サブスレッドでアニメーションを実行
dispatch_queue_t q = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(q, ^{
NSLog(@"main thread ? : %hhd",[NSThread isMainThread]); // -> NO
CGPoint p = layer.position;
p.x += 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim1"];
p.y += 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim2"];
p.x -= 200;
p.y -= 200;
[self enqueueAnimationForKeyPath:@"position"
fromValue:[NSValue valueWithPoint:layer.position]
toValue:[NSValue valueWithPoint:p]
duration:1.0f
animationKey:@"anim3"];
});
}
- (void)enqueueAnimationForKeyPath:(NSString*)keyPath fromValue:(id)fromValue toValue:(id)toValue duration:(NSTimeInterval)duration animationKey:(NSString*)animationKey;
{
CABasicAnimation *a = [CABasicAnimation animation];
a.duration = duration;
a.keyPath = keyPath;
a.removedOnCompletion = NO;
a.fillMode = kCAFillModeForwards;
a.fromValue = fromValue;
a.toValue = toValue;
[layer setValue:a.toValue forKey:keyPath];
[CATransaction begin];
CFRunLoopRef rl = CFRunLoopGetCurrent();
CFDateRef distantFuture = (__bridge CFDateRef)[NSDate distantFuture];
CFRunLoopTimerRef timer = CFRunLoopTimerCreate(NULL, CFDateGetAbsoluteTime(distantFuture), 0, 0, 0, NULL, NULL);
CFRunLoopAddTimer(rl, timer, kCFRunLoopDefaultMode);
[CATransaction setCompletionBlock:^{
NSLog(@"main thread ? : %hhd",[NSThread isMainThread]); // -> YES
CFRunLoopStop(rl);
NSLog(@"%@ was completed",animationKey);
}];
[layer addAnimation:a forKey:animationKey];
[CATransaction commit];
CFRunLoopRun();
CFRelease(timer);
CFRunLoopTimerInvalidate(timer);
}