Help us understand the problem. What is going on with this article?

Core Animation 中級編

More than 5 years have passed since last update.

8/19担当、Qiita初投稿の@inamiyです、こんにちは。

今回は、iOS/OSXアプリのUXの根幹である「Core Animation」について、
ハマりやすい点や、意外と知られていないtipsなどについて書きたいと思います。

アニメーション完了時のコールバック

Delegateでif分岐しながら処理する方法もありますが、
[CATransaction setCompletionBlock:]を使う方が、より見通しの良いコードになります。

問題は、アニメーションが正常終了したかどうかのfinishedフラグがないという点ですが、
下記のようなコードを書くと、上手く判別できます。

[CATransaction begin];
[CATransaction setCompletionBlock:^{

    CAAnimation* animation = [layer animationForKey:@"myAnimation"];

    if (animation) {
        // -animationDidStop:finished: の finished=YES に相当

        [layer removeAnimationForKey:@"myAnimation"]   // 後始末
    }
    else {
        // -animationDidStop:finished: の finished=NO に相当
    }

}];

CABasicAnimation* animation = [CABasicAnimation animationWithKeyPath:@"position.x"];
animation.duration = 3;
animation.byValue = [NSNumber numberWithDouble:100.0];

// completion処理用に、アニメーションが終了しても登録を残しておく
animation.removedOnCompletion = NO; 
animation.fillMode = kCAFillModeForwards;

[layer addAnimation:animation forKey:@"myAnimation"];

[CATransaction commit];

もし、CATransactionで毎回wrapするのが気に入らない場合は、
animationのdelegateオブジェクトを保持してblocksを実現する方法もあります。
CAAnimationBlocksというライブラリを使えば、CAAnimation毎に、
-setCompletion:でblockを登録することができます。

アニメーションを一時中断する

Technical Q&A QA1673: How to pause the animation of a layer treeが参考になります。
(下記例は CALayerのカテゴリで実装しています)

@implementation CALayer (TheWorld)

- (void)pauseAnimations 
{
    CFTimeInterval pausedTime = [self convertTime:CACurrentMediaTime() fromLayer:nil];
    self.speed = 0.0;    // 時よ止まれ
    self.timeOffset = pausedTime;
}

- (void)resumeAnimations
{
    CFTimeInterval pausedTime = [self timeOffset];
    self.speed = 1.0;    // そして時は動き出す
    self.timeOffset = 0.0;
    CFTimeInterval timeSincePause = [self convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
    self.beginTime = timeSincePause;
}

ここで、CAAnimationbeginTimeは通常、
親のCAAnimationGroupの時間軸におけるオフセットとして各animationに対して設定しますが、
上記例のようにグループを使わない場合は、レイヤー自体が親になるので、
CACurrentMediaTime()を使ってレイヤー時間に変換して計算する必要があります。

fillModeについて

普段、何気なく使っている(かもしれない)下記のおまじない

// よく分からないけど、アニメーションが終了してもちゃんと残る
animation.removedOnCompletion = NO; 
animation.fillMode = kCAFillModeForwards;

ですが、これもCAAnimationGroupを使って考えると分かりやすいです。

例えば、

CABasicAnimation* animation = [CABasicAnimation animationForKeyPath:@"position.x"];
animation.beginTime = 3;
animation.duration = 5;
animation.byValue = [NSNumber numberWithDouble:100.0];

CAAnimationGroup* group = [CAAnimationGroup animation];
group.animations = [NSArray arrayWithObject:animation];
group.duration = 10;
[layer addAnimation:group forKey:@"myGroupAnimation"];

を実行した場合、t=3〜8秒の間はアニメーションが実行されて表示されますが、
0〜3秒と8〜10秒は特に指定がなく、あいまいな状態になっています。
この場合、animation.fillModeの設定によって、

  • kCAFillModeRemoved ・・・表示しない(デフォルト)
  • kCAFillModeForwards・・・8〜10秒が終状態として表示される
  • kCAFillModeBackwards・・・0〜3秒が始状態として表示される
  • kCAFillModeBoth・・・backwards+forwards
  • kCAFillModeFrozen・・・(OSX 10.5でDeprecated)

となります。

なので、もしgroupを使わず、かつanimationのbeginTimeがデフォルトの0の
(前節のように、がんばって変換して代入でもしない)場合、
fillModeにforwards以外を設定する意味は特にないので、
基本的に上記おまじないを「使う」か「使わない」かの2択で考えて良いでしょう。
(使う場合は、あとでしっかり後始末しましょう)

暗黙的アニメーションが内部でやっていること

AppleのドキュメントCALayer Class Referenceによると、
暗黙的アニメーション(implicit animation)を実行したとき、
内部ではCALayer-actionForKey:が呼ばれ、
下記の順番でid <CAAction>オブジェクト(実質CAAnimation)を探索します。

  1. layer.delegateの-actionForLayer:forKey:
  2. layer.actions
  3. layer.style
  4. [CALayer defaultActionForKey:]

(途中で[NSNull null]が来ると探索をストップしてnil扱いになる)

もし、id <CAAction>オブジェクトが見つかった場合、-runActionForKey:object:arguments:が実行され、
これが

// selfは(id <CAAction>)オブジェクト、objectはlayer
[object addAnimation:self forKey:actionKey];

相当のコードを実行します。

ここで、Core Animationの特殊な挙動として、
もしCAPropertyAnimation (CAAnimationのサブクラス)に
keyPathが設定されないまま-addAnimation:forKey:が実行された場合、
そこで使われたanimationKeyがそのままkeyPathに代入されるので、例えば、

layer.opacity = 0.5

という暗黙的アニメーションを実行したら、
layer.animationKeysopacityキーが登録されつつ、opacity値を変化させるアニメーションが実行されます。

暗黙的アニメーションをデフォルトでOFFにする

毎回、[CATransaction setDisableActions:YES]を書くのは面倒なので、
前節の手順のどこかで[NSNull null]を渡してあげると良いです。
その場で簡単にセットできるのは、2.の方法になります。

layer.actions = [NSDictionary dictionaryWithObjectsAndKeys:
                 [NSNull null],@"onOrderIn",    // visibleになったときのフェードイン
                 [NSNull null],@"onOrderOut",   // invisibleになったときのフェードアウト
                 [NSNull null],@"sublayers",
                 [NSNull null],@"contents",
                 [NSNull null],@"position",
                 [NSNull null],@"bounds",
                 [NSNull null],@"transform",
                 nil];

なお、UIViewがwrapしているレイヤー(view.layer)が暗黙的アニメーションをしないのは、1.の方法によるもので、
view.layer.delegate(=view)が-actionForLayer:forKey:内で常にNSNullを返すためです。

カスタムプロパティをanimatableにする

CALayerのサブクラスを作り、+needsDisplayForKey:
-actionForKey:-drawInContext:をオーバーライドして、
再描画を繰り返すと、animatableに見せることができます。

@implementation MyLayer

+ (BOOL)needsDisplayForKey:(NSString*)key
{
    if ([key isEqualToString:@"angle"]) {
        return YES;
    }

    return [super needsDisplayForKey:key];
}

- (id <CAAction>)actionForKey:(NSString*)key
{
    if ([key isEqualToString:@"angle"]) {

        CABasicAnimation* animation = [CABasicAnimation animation];
        animation.duration = 1; // デフォルトを通常(0.25)より少し遅めにセット
        return animation;

    }

    return [super actionForKey:key];
}

- (void)drawInContext:(CGContextRef)context
{
    // angle値に応じて描画する
}

@end

おわりに(ライブラリ紹介)

以前、Core Animationを使って、起動画面(スプラッシュ)を好きなようにトランジションさせながら
アプリを開始する簡単なクラスを書いたので、良かったら使ってみてください。

その他にも、アニメーションまわりの良ライブラリを、いくつか紹介します。

下2つは特に有名ですね。ぜひ、お時間のあるときにソースを読んでみましょう。

では、良い月曜日を!

inamiy
iOS Developer
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away