SNOOZE LOG

iOS中心のプログラミング関連技術メモ

深く知りたい Core Animation まとめ2(アニメーション編)【iOS / Swift】

f:id:snoozelag:20211027105553j:plain

CoreAnimationは、iOSOS Xの両方で利用可能なグラフィックスレンダリングアニメーションのインフラストラクチャです。

Core Animation その1(レイヤー編)の記事はこちら

記事作成時の環境: Xcode13.0、iOS15、Swift 5
サンプルコード集 CoreAnimationZuroku を公開しています。

暗黙的なアニメーション

Core Animation が実行するアニメーションには「暗黙的アニメーション」と「明示的アニメーション」があり、暗黙的アニメーションとは、アプリのプログラマが特別に指示しなくても、Core Animation が自動的に作成するアニメーションです。

myLayer.opacity = 0.0

f:id:snoozelag:20211210155749g:plain:w300

具体的には、このように単にレイヤーのプロパティを変更するだけで、アニメーション可能なプロパティならば、Core Animation がアニメーションを作成しスケジュールします。アニメーション可能なプロパティかどうかは、この一覧表で確認するか、CALayerのそのプロパティのドキュメントを見るとAnimatableと記載があります。暗黙的アニメーションは通常はデフォルトのタイミングとスタイルで実行されます。

・暗黙のアニメーションをトリガーするには、レイヤーオブジェクトのプロパティを更新するだけです。たとえば、レイヤーの不透明度を1.0から0.0に変更すると、レイヤーがフェードアウトして透明になります。

・モデルレイヤーツリーのレイヤーオブジェクトが変更されると値はすぐに反映されます。ただし、外観はすぐには変更されず、Core Animation は変更をトリガーとして暗黙的なアニメーションを作成およびスケジュールします。

・暗黙的なアニメーションでは、デフォルトのタイミングプロパティとアニメーションが使用されます。



暗黙的アニメーションの無効化

レイヤーのアニメータブルなプロパティを変更時に、暗黙的アニメーションを発生させずに変更する方法を3つ紹介します。

(その1)CATransactionを使用します。トランザクションについては後ほどで項目を設けています。

CATransaction.begin()
CATransaction.setDisableActions(true)
myLayer.opacity = 0.0 // アニメーション化をしたくないプロパティ変更
CATransaction.commit()

(その2)初期値の変更については style 辞書へのセットも利用できます。アニメーション化せずにレイヤープロパティの初期値を変更できます。

myLayer.style = ["opacity": 0.0] // keyPath: 値 の組みをセット。アニメーション化せず、そのプロパティの初期値が変更されます。

(その3)そもそも、UIView直属のlayerオブジェクト(アンダーライングレイヤー)はデフォルトでアニメーション化が無効になっています。

let view = UIView()
// そもそもUIView直属のlayerオブジェクトはプロパティ変更してもアニメーション化されない。
view.layer.opacity = 0.0 // view.alpha も共通して値が 0.0 に変更される



暗黙的アニメーションのデフォルトを変更

レイヤーのプロパティ変更などでトリガーされるアニメーションを、デフォルト以外にカスタムすることも出来ます。
大筋としては、アクションオブジェクトとして別のアニメーション等を作成し、レイヤーの適切なイベントタイミングに配置することによって実現します。

・プロパティ変更による暗黙のアニメーション化は、アクションのひとつです。
・Core Animationは、レイヤープロパティが変更されるたびに実行されるデフォルトのアクションオブジェクトとして、CAAnimationオブジェクトを割り当て、実行しています。

アクションオブジェクトとはCAActionプロトコルに準拠したオブジェクトのことで、レイヤーの暗黙的なアニメーション動作は、これに準拠したCAAnimationオブジェクトがレイヤーにデフォルトでセットされており、実行されているためです。プロパティ変更時に実行されるアクションオブジェクトは、アニメーションでないものに変更することも可能です。アクションを別のものに関連づけることで、通常の暗黙的アニメーションの挙動を変更することが出来ます。

レイヤーのアクションオブジェクトの変更手順

(1) カスタムのアクションを定義
(2) 定義したアクションを、アプリのレイヤーオブジェクトに関連付ける。

レイヤーに関連付けるアクションには、CAActionプロトコルを採用した独自のオブジェクト(run(forKey:object:arguments:) を実装)を定義するか、CAAnimationオブジェクトを利用できます(既にCAActionに準拠しているため)。

アクションの検索順序と差し替え

レイヤーでイベントが発生すると、そのイベントのキーに対応するアクションが、レイヤーで検索されます。
この検索中の、いくつかのポイントにカスタムアクションを設置し、そのイベントのキーに関連づけを行うことでアクションを変更できます。

カスタムアクションをどのポイントに設置するかは、レイヤーの変更内容によって異なります。

(0)レイヤーでイベント(アクションがトリガーされる状況)が発生

アクションがトリガーされる状況 アクションを識別するキー
レイヤーのプロパティの値が変更された
アニメータブルなプロパティだけでなく、任意のプロパティ、追加したカスタムプロパティなどにも関連付けられます。
プロパティ名 <例>"contents" "opacity"
レイヤーが表示されるかレイヤー階層に追加された kCAOnOrderIn
レイヤーが階層から削除された kCAOnOrderOut
レイヤーがトランジションアニメーションに関わろうとする時 kCATransition

(1) イベントの発生したレイヤーにデリゲートの設定がありaction(for:forKey:)メソッドを実装しているかを確認します。
確認された場合、このメソッドを呼び出します。action(for:forKey:)には、次のいずれかを実装する必要があります。

・指定されたキーのアクションオブジェクトを返す。
nilを返す。(この場合、検索は続行される)
NSNullオブジェクトを返す。(この場合、検索はここで終了する)

デリゲートオブジェクトを使用しているレイヤーの場合、特定の状況下でのみ適用されるアクションの場合は、この `action(for:forKey:)` メソッドを実装してアクションを提供する方法が適しています。UIView直属の `layer` オブジェクトの場合は、UIViewのサブクラスを作成し `func action(for:forKey:)`をオーバーライドします。

(2) (ステップ1でアクションが提供されなかった場合)レイヤーのsuper.action(forKey:)が実行され、actions 辞書にイベントのキーのアクションが存在するかどうかが確認されます。

デリゲートオブジェクトを使用しないレイヤーの場合は `actions` 辞書に追加して、カスタムアクションを提供できます。

(3) (ステップ2でアクションが提供されなかった場合)レイヤーのstyle 辞書にイベントのキーのアクションが存在するかどうかが確認されます。

カスタムプロパティに関連するアクションについては、そのアクションをこの `style` 辞書に含めます。(それ以外のプロパティについては `actions` 辞書を利用します。)

(4) (ステップ3でアクションが提供されなかった場合)クラスメソッドであるdefaultAction(forKey:)にイベントのキーのアクションが存在するかどうかを確認します。

レイヤーの動作の基本となるアクションについては、レイヤーをサブクラス化して `defaultAction(forKey:)` メソッドをオーバーライドします。

(5) (ステップ4でアクションが提供されなかった場合)レイヤーは、Core Animationで定義された暗黙のアクション(存在する場合)を実行します。


これまでの検索の中で、独自のアクションの実装が見つからなかった場合に実行されます。SDKに実装されているデフォルトのアクションが使用されますが、このデフォルトのアクション自体はオブジェクトとして取得することはできません。

参考: ios - CALayer: Where does the implicit animation comes from? - Stack Overflow

特定のキーに対応したアクションの実装が確認された段階で、そのアクションの実行が行われます。

例として、次のコードはcontentsプロパティの変更時のイベントのアクションとして、actions にCAAnimation オブジェクトを設定するものです。

デフォルトでは contents 変更では見た目に変わったことは起きず、単に画像がパッと現れる/消えるだけですが、
レイヤーに画像がセットor削除されたとき、遷移アニメーション化されるように変更できます。

let myAnimation = CATransition()
myAnimation.duration = 1
myAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn)
myAnimation.type = .push
myAnimation.subtype = .fromRight
layer.actions = ["contents": myAnimation]

f:id:snoozelag:20211210113002g:plain:w450
サンプルコード集 CoreAnimationZuroku に収録しています。

style 辞書の使い方

追加されたカスタムプロパティのアクションについては style 辞書が検索される、と書きました。
この style 辞書の別の役割は、アニメーションさせずに、レイヤーのプロパティのデフォルトを登録した値に変更できる機能です。

レイヤーのアニメーション可能なプロパティは、変更を行うと暗黙的アニメーションが生成されてしまいます。特に初期の設定段階では、アニメーションをさせたくないこともあると思います。そういった場合に、アニメーションなしでレイヤーの初期値を変更することができます。例えば、次のようなコードではレイヤーのデフォルト背景色が黄色に変更されます。

layer.style = ["backgroundColor": UIColor.yellow.cgColor] // デフォルトの背景色が黄色に変更されます



明示的なアニメーション

明示的なアニメーションとは、自動的に作成されたものでなく、プログラマがアニメーションオブジェクトを作成しパラメータを指定して実行するアニメーションです。


継承関係まとめ

Core Animationでは、以下のオブジェクトが利用できますが、継承関係の構成を把握するためまとめておきます。

CALayerCAMediaTimingCAActionプロトコルに適合) → NSObject
CAAnimationCAMediaTimingCAActionプロトコルに適合) → NSObject
CAPropertyAnimationCAValueFunctionを包含)→ CAAnimation …
CATransition → CAAnimation …
CABasicAnimation → CAPropertyAnimation → CAAnimation …
CAKeyframeAnimation → CAPropertyAnimation → CAAnimation …
CASpringAnimation → CABasicAnimation → CAPropertyAnimation → CAAnimation …

CABasicAnimation


「アニメーション化するプロパティのキーパス(keyPath)」「開始値(fromValue)」「終了値(toValue)」「期間(duration)」を指定した例:

「赤→青」とbackgroundColorが変化するアニメーションです。

let keyPath = #keyPath(CALayer.backgroundColor)
let fromValue = UIColor.red.cgColor
let toValue = UIColor.blue.cgColor

let animation = CABasicAnimation(keyPath: keyPath)
animation.fromValue = fromValue
animation.toValue = toValue
animation.duration = 4
myLayer.add(animation, forKey: keyPath)

myLayer.backgroundColor = toValue // アニメーションの終了値を反映する場合はセットする必要がある(*1)

f:id:snoozelag:20211210111513g:plain:w300

fromValueの指定がない場合、レイヤーの現在の値が使用されます。(fromValue の値は割り当てることが推奨されています。)

作成したアニメーションオブジェクトは、次のメソッドでレイヤーに渡します。

CALayer#add(_:forKey:)・・・指定したアニメーションオブジェクトをレイヤーのレンダーツリーに追加します。

レンダーツリー・・・レイヤーには「(モデル)レイヤーツリー」「プレゼンテーションツリー」「レンダーツリー」の3セットの概念があり、レンダーツリーに属するオブジェクトは アニメーション実行時のレンダリング処理で使用されます。Core Animation 専用のため、アプリのプログラマはオブジェクトにアクセスできません。

add(_:forKey:)のキーとして指定する文字列は、任意の文字列を指定することが可能です。これは removeAnimation(forKey:) などのメソッドで、追加したアニメーションを特定するために用いられます。

・アニメーションの最後に、Core Animation はアニメーションオブジェクトをレイヤーから削除し、現在のデータ値を使用してレイヤーを再描画します。

アプリのプログラマが操作している(アニメーションオブジェクトを渡す)レイヤーは、モデルレイヤーツリーに属するオブジェクトになります。 実行したアニメーションの終了値はアニメーションとして画面でレンダーツリーが使用されて画面で再生されますが、(モデルレイヤーツリーの)レイヤーには値として反映されないため、コメント文(*1)の行で、アニメーションの終了時点と同じ値にするために値としてレイヤーにセットします。(セットしない場合は、終了時点でアニメーション実行以前の状態で表示されます。)


CAKeyframeAnimation

CAKeyframeAnimation は、ターゲットとなる値のセットを直線的にアニメーションさせることができます。**「一連のターゲットデータの値」と「各値に到達する時間」で構成されます。最もシンプルな構成では、値と時間の両方を配列で指定します。position をパス(CGPath/CGMutablePath)に沿って変化させることもできます。アニメーションオブジェクトは、指定された期間にわたり、ある値から次の値へと補間することでアニメーションを構築します。

CABasicAnimationではそのアニメーションの期間において、ターゲットとなるポイントの設定は一つ(toVlaue)だったわけですが、CAKeyFrameAnimationではそのポイントを複数設定できます。

CABasicAnimationと比較できるようなサンプルとしてCAKeyFrameAnimationを用いたものに書き換えました。また色を一色増やして「赤→青」ではなく「赤→黄→青」とbackgroundColorが変化するアニメーションにしています。

let keyPath = #keyPath(CALayer.backgroundColor)
let keyTimes: [NSNumber] = [0, 0.5, 1]
let values = [UIColor.red.cgColor, UIColor.yellow.cgColor, UIColor.blue.cgColor]
let duration: CFTimeInterval = 4

let keyframeAnimation = CAKeyframeAnimation(keyPath: keyPath)
keyframeAnimation.calculationMode = .linear
keyframeAnimation.repeatCount = .greatestFiniteMagnitude
keyframeAnimation.keyTimes = keyTimes
keyframeAnimation.values = values
keyframeAnimation.duration = duration
keyframeAnimation.delegate = self
layer.add(keyframeAnimation, forKey: keyPath)

layer.backgroundColor = toValue // アニメーションの結果を反映する場合はセットする(*1)

CAKeyFrameAnimationの設定で重要と思うのは、keyTimesvaluesに指定する配列の要素の個数や内容はcalculationModeによって異なることです。

CAKeyframeAnimation#calculationModeプロパティ・・・キーフレーム値の中間値を計算する方法の指定です。デフォルトはlinearモードです。
linear・・・線形的な中間値が補間されます。
discrete・・・補間値は計算されません。キーフレームの値が順番に使用されます。
paced・・・線形補間されますがkeyTimestimingFunctionは無視され、アニメーションが一定の速度(ペース)となるようキーフレームタイムが自動的に生成されます。
cubic・・・Catmull-Rom Spline補完。キーフレーム値が曲線的になめらかに結ばれるような中間値で補間されます。
cubicPaced・・・(cubicモードよりも)直線的な補間がされます。keyTimestimingFunctionは無視され、アニメーションが一定の速度(ペース)となるようキーフレームタイムが自動的に生成されます。

CAKeyframeAnimation#keyTimes プロパティ・・・キーフレームとなる時間を定義する配列。

<keyTimes配列に含む値の条件>
0.0〜1.0 の間の浮動小数点数。配列の最初の値は 0.0 で最後の値は 1.0 となります。
・配列の連続する各値は「前の値よりも大きい」または「同じ」であること。
・keyTimes 配列内の「無効な値」「calculationMode に不適切な値」は無視されます。
・配列に入れる適切な値は calculationMode プロパティに依存します。
pacedcubicPacedの場合:このプロパティの値は無視されます。
linearcubicの場合:keyTimes の要素数と、values の要素数または path の制御点の数は一致させます。
discreteの場合:keyTimes の要素数より、values の要素数または path の制御点の数は1つ少なくなります。values の最後の値は、keyTimes の最後の一つ前から最後の値(1.0)に対応する期間に設定されるため。(例:要素数keyTimesが3個ならばvaluesは2個設定する。)

CAAnimationCalculationMode のドキュメントには、各 calcurationMode の変化の図が記載されていてわかりやすいです。



CAAnimationGroup

・複数のアニメーションをレイヤーに同時に適用する場合は、CAAnimationGroup オブジェクトを使用してグループ化できます。
・グループ化した個々のアニメーションのタイミングプロパティとdurationは、CAAnimationGroup オブジェクトに設定した値で上書きされます。

// アニメーション1
let bgAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.backgroundColor))
bgAnimation.fromValue = UIColor.red.cgColor
bgAnimation.toValue = UIColor.blue.cgColor

// アニメーション2
let positionYAnimation = CAKeyframeAnimation(keyPath: "position.y")
positionYAnimation.calculationMode = .cubic
positionYAnimation.keyTimes = [0, 0.25, 0.5, 0.75, 1]
positionYAnimation.values = [310, 60, 120, 60, 310]

// アニメーショングループ
let animationGroup = CAAnimationGroup()
animationGroup.animations = [bgAnimation, positionYAnimation]
animationGroup.duration = 6
layer.add(animationGroup, forKey: "MyAnimationGroup")

f:id:snoozelag:20211210005416g:plain:w300

アニメーションの制御


実行中に停止させる

追加したアニメーションオブジェクトそのものを削除することで、完全停止させることができます。

・通常、アニメーションは完了するまで実行されますが、再生の途中で、オブジェクトを削除することで再生を停止することができます。アニメーションオブジェクトが削除されると、Core Animation はレイヤーの現在の値で再描画します。

■ レイヤーから単一のアニメーションを削除する

removeAnimation(forKey:)・・・キーで指定したアニメーションを削除します。add(_:forKey:)で指定したキーを使用しアニメーションを識別します。

■ レイヤーからすべてのアニメーションを削除する

removeAllAnimations()・・・レイヤーに割り当て中のアニメーションを全て削除

■ アニメーション中に停止させた時、最後の表示フレームを維持する

レイヤーの外観をアニメーションの最後のフレームの位置のままにしておきたい場合は、プレゼンテーションツリーのオブジェクトを使用してそれらの最終値を取得し、レイヤーツリーのオブジェクトに設定できます。

// アニメーション再生中に実行。presentation()から変化中の値を取得できる
let opacityDuringAnimation = animatingLayer.presentation()?.opacity 
myLayer.opacity = opacityDuringAnimation
myLayer.removeAnimation(forKey: "OpacityAnimation")



アニメーション終了時を検知する

アニメーション終了の通知を受ける方法は2つあります。

トランザクションのsetCompletionBlock()メソッドの使用
トランザクション内のすべてのアニメーションが終了すると、トランザクションは完了ブロックを実行します。

暗黙的アニメーションの終了を検知するのに役立ちます。

CATransaction.begin()
CATransaction.setCompletionBlock {
    // アニメーション完了後に実行したいことをここに記述
}
myLayer.position = CGPoint(x: 60, y: 60) // アニメーション化したいプロパティ変更
CATransaction.commit()


・CAAnimationオブジェクトにデリゲートを割り当て animationDidStart(_:) および animationDidStop(_:finished:) tメソッドを実装する。
∟アニメーションに repeatCount が設定されている場合、カウント分すべての再生が終わってからコールバックされます。
finished フラグは、アニメーションオブジェクトが削除されず再生期間を完了した場合 true 、削除されて終了した場合は false です。

主に明示的アニメーションの終了の検知に役立ちます。

・・・
    animation.delegate = self
・・・
extension ViewController: CAAnimationDelegate {

    func animationDidStop(_ animation: CAAnimation, finished: Bool) {
         // アニメーション完了後に実行したいことをここに記述
    }   
}


ところで、

If you want to chain two animations together so that one starts when the other finishes, do not use animation notifications. Instead, use the beginTime property of your animation objects to start each one at the desired time. To chain two animations together, set the start time of the second animation to the end time of the first animation.

2つのアニメーションをチェーンして、一方が終了したときに開始するようにする場合は、アニメーション通知を使用しないでください。代わりに、beginTimeプロパティを使って、2つ目のアニメーションの開始時間を、1つ目のアニメーションの終了時間に設定します。
と公式ドキュメントに記載があり、通知ではハウスキーピングタスクを行うのに適している、とも。アニメーションの連結にはタイミングプロパティ調整した方法が推奨されているようです。しかしドキュメントに完全なコードが示されておらず、ハードルが高いのでは、と思います(私も調査と検証に苦労したため)。タイミングプロパティを使用したアニメーション連結は次の項目でコードつきで説明しています。

単純な連結であればCAAnimationDelegateCATransaction.setCompletionBlock()での通知を使用したアニメーションの同期を使用する方が他のAPIと同じ形式で慣れているし簡単であると言えます。ただし、アニメーションの一時停止・再開などを処理として含む場合には、通知でのアニメーション制御はコードが複雑になると思われます。



タイミングを制御する

・アニメーションのタイミング情報を指定するには CAMediaTiming プロトコルのメソッドとプロパティを使用します。(beginTimetimeOffsetrepeatCountrepeatDurationdurationspeedautoreversesfillModeプロパティを定義)

・Core Animation の CAAnimationCALayer が、この CAMediaTiming プロトコルを採用しています。これらアニメーションの(実行時に作成される)暗黙的トランザクションはデフォルトのタイミング情報を提供します。
∟ CAAnimation:明示的アニメーションのタイミングを指定できます。
∟ CALayer:レイヤー上で実行される明示的/暗黙的アニメーションのタイミングが変更できます。

・各レイヤーはアニメーションのタイミングを管理するローカル時間を持っています。ローカル時間は、親レイヤーまたはレイヤー自身の(CAMediaTimingプロトコルの)タイミングパラメーターによって変更できます。(例)親レイヤーの speed プロパティを変更すると、そのレイヤーとサブレイヤーのアニメーションの duration が比例して変化します。

私の検証によると speed プロパティは layeranimation オブジェクトの乗算した結果になります。例えば、layer.speed = 0.1 と10分の1倍速を設定したレイヤーに animation.speed = 10.0 10倍速に設定したアニメーションオブジェクトを追加して実行すると、ちょうど1倍速で再生されます。これは親レイヤーと子レイヤーの関係でも同じになり、アニメーションのタイミング属性は階層的に影響します。

タイミングプロパティを使うと、次のようなアニメーションを実現できます。

■ 2つのアニメーションを連動させるグループアニメーション

タイミングプロパティを使った連動は、アニメーションの終了時間と、別のアニメーションの開始時間を合わせることで実現できます。

CAAnimationGroup を使って2つのアニメーションをグループ化し、各アニメーションの終了と開始のタイミングプロパティを調節して連結します。例として、移動して徐々に消えるアニメーションを作成しました。アニメーション1(左上に移動)とアニメーション2(徐々に消える)を連結したコードは下記のようになります。

let animation1 = CABasicAnimation(keyPath: #keyPath(CALayer.position))
let targetPosition = CGPoint(x: 60, y: 60)
animation1.fromValue = subLayer.position
animation1.toValue = targetPosition
animation1.duration = 2.0
animation1.beginTime = 0
var totalDuration = animation1.duration

let animation2 = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
let targetOpacity: Float = 0
animation2.fromValue = 1
animation2.toValue = targetOpacity
animation2.duration = 2.0
animation2.beginTime = animation2.duration // 2.0秒
animation2.fillMode = .backwards
totalDuration += animation2.duration

let group = CAAnimationGroup()
group.duration = totalDuration
group.animations = [animation1, animation2]
subLayer.position = targetPosition // (※)
subLayer.opacity = targetOpacity // (※)
subLayer.add(group, forKey: "MyChainAnimation")

参考: objective c - Chaining Core Animation animations - Stack Overflow

CAAnimationGroupを使用して連携をとる場合、グループオブジェクトが追加した子アニメーションの親オブジェクトとなるため、 子アニメーションの開始時間はCAAnimationGroupオブジェクトとの相対値(以下の例では、単純な遅延秒数の指定で機能します)を設定します。 beginTimeのデフォルトは0です。この例では、アニメーション1がすぐに開始され、2秒後にアニメーション2が開始されます。

アニメーション後の描画状態をレイヤーに維持したいと考えた場合、アニメーションの終了値をレイヤーのプロパティにアニメーション開始前にセットします。(コードの(※)の部分)。この場合、アニメーション2の開始は2秒待機していますので、通常ならば即座にレイヤーに終了状態が描画されてしまいますが、それを防ぐためにfillModeをbackwardsに設定すると、セットされている値と関係なく、アニメーション開始前のプレゼンテーション(表示)を維持できます。

beginTimeプロパティ・・・親オブジェクトに関連するレシーバーの開始時刻を指定します。通常、アニメーションは次の更新サイクルで開始されますが、beginTimeパラメータを使用すると開始時間を遅らせられます。

CAMediaTimingFillMode.backwards・・・アニメーションの最終値に合わせてモデルレイヤーの値を更新すると、beginTimeを設定して開始を遅延させているレイヤーの表示が、アニメーションの開始時刻になるまえに最終値にジャンプしてしまいます。これを避けるためにレイヤーの値とは関係なくアニメーションの開始値でプレゼンテーションを表示しておくことができます。



レイヤーのタイミング制御

speed などのタイミングプロパティの変更によって、同じ duration 値が設定されていても、親レイヤーまたはレイヤー自身で実行される実際の再生時間などが異なってきます。

レイヤー同士の親子関係、アニメーションタイミングを合わせるためには CALayer#convertTime(_:from:)convertTime(_:to:)を使用して変換を行います。

let convertedCurrentTime = layer.convertTime(CACurrentMediaTime(), from: nil)

CACurrentMediaTime()・・・コンピュータの現在の絶対時間を取得できます。

任意のある時点からCPUがインクリメントしている時間の数値を、秒数単位になおしたもののようです。イメージを掴むために print を実行してみます。

参考: mach_absolute_time()iOS の時間関数の精度 - Qiita

f:id:snoozelag:20211207162130p:plain:w400

レイヤーのbeginTimetimeOffsetといったタイミングプロパティを指定する場合には、この時間値を取得する必要があるとのこと。

CAAnimationGroupを使用した例では、子アニメーションオブジェクトへはchildAnimation.beginTime = 2のような形で2秒の遅延を設定できましたが、 childLayer.beginTime = CACurrentMediaTime() + 2といった形になります。

以下は、レイヤーのプロパティ変更による暗黙的アニメーションの遅延を設定するコードです。

タイミングプロパティの変更は、自身または親子関係で階層的に影響します。タイムスペースを変換する convertTime(:for) の利用には、
通常、メソッドをコールするレシーバーには子レイヤーオブジェクトが、変換元となる from: 引数には親レイヤーオブジェクトが(あれば)割り当たります。

// メディアタイムを、レイヤーのタイムスペース上の時間値に変換する
let currentTimeInSuperLayer = superLayer.convertTime(CACurrentMediaTime(), from: nil) // 親となるレイヤーがない場合は nil 
let currentTimeInSubLayer = subLayer.convertTime(currentTimeInSuperLayer, from: superLayer) // 親となるレイヤーをfromに指定

// 暗黙的アニメーションは3秒遅れて実行されます
let delay: Float = 3
subLayer.beginTime = currentTimeInSubLayer + delay
subLayer.fillMode = .backwards
subLayer.position = CGPoint(x: 60, y: 60)

f:id:snoozelag:20211210005533g:plain:w300

参考: Time Warp in Animation

その他、autoreversesプロパティ、repeatCountプロパティなどを利用することで、様々なアニメーション表現を行うことができます。

これらのタイミングプロパティを変更とアニメーションが変化をビジュアル化した説明が、こちらのサイト (Controlling Animation Timing)http://ronnqvi.st/controlling-animation-timing で見られます。非常に分かりやすく参考になりましたので紹介しておきます。

ここまでの話で beginTime の使用上の結論をまとめておきます。

・アニメーショングループ の 子アニメーションでは 開始遅延させる秒数 だけを指定する。
・レイヤーでは 開始遅延させる秒数 と CACurrentMediaTime() を加味した値を指定する。(正確には自分・親子関係で階層的な時間変換が必要。)



一時停止および再開

アニメーションを一時停止するには、タイミング制御の実装を用いることで実現できます。

CALayerCAMediaTimingプロトコルを採用しているため、speed プロパティを持ち、それを 0.0 に設定するとゼロ以外の値に戻すまでアニメーションが一時停止します。

■ アニメーションの一時停止と再開

// 一時停止
func pauseLayer(_ layer: CALayer) {
   let pausedTime: CFTimeInterval = layer.convertTime(CACurrentMediaTime(), from: nil)
   layer.speed = 0.0
   layer.timeOffset = pausedTime
}

// 再開
func resumeLayer(_ layer: CALayer) {
   let pausedTime: CFTimeInterval = layer.timeOffset
   layer.speed = 1.0
   layer.timeOffset = 0.0
   layer.beginTime = 0.0 // 0をセットすることで、再びアクティブになったレイヤーをグローバル時間に一致させる
   let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil)
   layer.beginTime = timeSincePause // 再生開始のオフセットを一時停止した時に移動する
}

このコードはAdvanced Animation Tricksドキュメントからの抜粋です。

timeOffsetプロパティのデフォルトは0で、例えば、duration3秒のアニメーションで
このプロパティに1をセットしたならば、1秒経過した時点にジャンプした状態からアニメーションが再生できます。CAMediaTiming.h ヘッダのコメントにあるように、このプロパティの使用目的の一つは、speedプロパティと組み合わせたレイヤーのアニメーション表示の一時停止です。

f:id:snoozelag:20211208063741p:plain:w700

timeOffsetの効果については、 (Controlling Animation Timing)http://ronnqvi.st/controlling-animation-timing もしくは Wangling - Time Warp in Animation の記事が非常に分かりやすいです。

この例の「一時停止」では、 layer.speed = 0 をセットし、 timeOffset には、ローカルタイムに変換した停止された時点のメディアタイムである pausedTime をセットすることで、ポーズ操作時点のアニメーション効果をレイヤー上で維持しています。

「再開」については、layer.speed = 1layer.timeOffset = 0 と「一時停止」で操作したタイミングプロパティをデフォルト値に戻します。timeOffsetはアニメーションの再生フレームの先頭オフセットをそのdurationの中でずらす効果があり、speed = 0状態での利用を念頭においている側面があるからです。

アニメーションが再びアクティブに戻った場合、アニメーションの途中から開始するためにはbeginTimeを操作します。ドキュメントにこのプロパティの詳しい説明はありませんが、こちらに素晴らしい検証 ios - Comprehend pause and resume animation on a layer - Stack Overflow があります。利用上の結論をだけをまとめます。

・グローバル時間と同じタイミングのレイヤー上では、beginTime に5.0を指定すると、グローバル時間からみて 5.0秒前のアニメーションからやり直すことが出来る。

そのレイヤーのアニメーションの duration を一時停止していた期間が長いと、基準をグローバル時間(もしくはそのローカル時間)に戻すと、そのアニメーションは既に終了していることになります。そこで beginTime に停止していた期間を指定すると、そのレイヤー内の時間空間でアニメーションを止めた時間に、タイムを戻すことが出来ます。

再びアクティブになった(speed = 1 になり時間が進み始めた)レイヤー上でlayer.beginTime = 0を設定することで、時間が停止されていたローカル時間(ローカル時間はspeed = 0 なのでインクリメントされない)を、グローバル時間に一致させる(ローカル時間は加算され timePause + timeSincePause となる)効果があるようです。停止が長かった場合、グローバル時間基準になると、アニメーションの duration である再生時間は過ぎ去っていて再生場面が飛んだような状態の時刻になっているので、停止していた時間(timeSincePause)を beginTimeにセットすることで再生時間のオフセットをずらし、一時停止していた時間秒前の状態のアニメーションからやり直します。



トランザクション

レイヤーでアニメーションが追加されるたびにトランザクションは自動的に生成されており、Core Animation によって発生したものは暗黙的トランザクションと呼ばれています。
アプリのプログラマが意図的にCATransactionクラス を使用して発行するは明示的トランザクションと呼びます。

・暗黙的なトランザクションは、レイヤーに明示的または暗黙的なアニメーションを追加したとき(レイヤーツリーが変更されたとき)自動的に作成され、スレッドの RunLoop が次に反復するときに自動的にコミットされます。

CATransaction・・・複数のレイヤーツリー操作を、レンダーツリーのアトミックな更新にまとめるためのメカニズムです。



明示的トランザクションの使い所

・レイヤーのプロパティ変更による暗黙的アニメーションの挙動の変更したり、正確に管理したいとき
・各アニメーションのセットごとに異なるパラメータ(durationtimingFunction)をオーバーライド(上書き)出来る(CAAnimationGroupを使用したグループ化より柔軟らしい)
・アニメーションに完了ブロックを設置できる

func setValue(Any?, forKey: String) メソッドではトラザクションに含む変更としてキーバリュー形式でセットします。
このメソッド用のキーとしては CATransaction properties に4つ定義されています。また、それぞれに convenience method が用意されていてそちらも同じように使えます。

let kCATransactionAnimationDuration: String・・・アニメーションの継続時間をオーバーライドして変更できる。秒単位で指定。
setAnimationDuration(_:)と同じ
let kCATransactionDisableActions: String・・・trueをセットすると、プロパティ変更に対する暗黙的アニメーション(アクション)をオーバーライドして抑制できる。
setDisableActions(_:)と同じ。
let kCATransactionAnimationTimingFunction: String・・・アニメーションのタイミングカーブである timingFunctionをオーバーライドし変更できる
setAnimationTimingFunction(_:)と同じ。
let kCATransactionCompletionBlock: String・・・バリューとしてブロックをセットできる
setCompletionBlock(_:)と同じ。

使用方法:

新しいトランザクションの開始は CATransaction.begin() をコールします。
トランザクションの終了するには CATransaction.commit() をコールします。
アニメーションを変更したいレイヤーへのプロパティ変更を、このトランザクションの範囲に挟みこむように記述します。

■ 暗黙的アニメーションの無効化

UIViewの直属でないレイヤーオブジェクトのプロパティを変更すると、暗黙的に(Core Animation による自動の)アニメーション化しますが
これを無効にできます。次のmyLayer.opacity = 0.0の変更はアニメーションされません。

CATransaction.begin()
CATransaction.setDisableActions(true)
myLayer.opacity = 0.0
CATransaction.commit()

(余談)UIViewの直属のレイヤーオブジェクトのアニメーションをトランザクションで有効にも出来るのか?→出来ませんでした。

// 試したコード。トランザクションを使用した直属のレイヤーオブジェクトのアニメーション化は不可能だった
CATransaction.begin()
CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(2)
fooView.layer.opacity = 0
CATransaction.commit()

■ 暗黙的アニメーションのデフォルトの duration を変更

次のサンプルコードでは、暗黙的アニメーションの期間がデフォルトの 0.25秒から3秒に変更されます。

CATransaction.begin()
CATransaction.setAnimationDuration(3)
myLayer.opacity = 0 
CATransaction.commit()

トランザクションはネストできる

・最も外側のトランザクションの変更が commit() された後、Core Animation はアニメーションを開始します。

CATransaction.begin() //外部トランザクション
// アニメーションの長さを2秒に変更します
CATransaction.setAnimationDuration(2)
//レイヤーを新しい位置に移動します
myLayer.position = .zero

CATransaction.begin()  // 内部トランザクション
//アニメーションの長さを5秒に変更します
CATransaction.setAnimationDuration(5)
// zPositionと不透明度を変更します
myLayer.zPosition = 200.0
myLayer.opacity = 0.0
CATransaction.commit() // 内部トランザクション

CATransaction.commit() // 外部トランザクション

■ UITableView などの既存クラスのアニメーションに completion ブロックを設ける

もともとAPIデザインとして引数が設定されていない既存のクラスに対し、アニメーションに関連した処理をつけ加えるアプローチで、
少しハック感のある使用方法です。

UITableView#insertRows(at:with:)メソッドなど、 もともと引数が用意さていないメソッドに completion ブロックを設けます。

CATransaction.begin()
tableView.beginUpdates()
CATransaction.setCompletionBlock {
    // UITableViewのセル挿入アニメーション完了後に実行したいことをここに記述
}
tableView.insertRows(at: indexArray, with: .top)
tableView.endUpdates()
CATransaction.commit()

参考: ios - UITableView row animation duration and completion callback - Stack Overflow

UINavigationController#pushViewController(_:animated:)popViewControllerAnimated()メソッドなどに completion ブロックを付加する

CATransaction.begin()
CATransaction.setCompletionBlock {
// 遷移アニメーション完了後の処理をここへ書く
}
navigationController?.pushViewController(detailVC, animated: true)
CATransaction.commit()

参考: ios - Completion handler for UINavigationController "pushViewController:animated"? - Stack Overflow

UINavigationController の push / pop 遷移アニメーションを自前のものに変更する。

CATransaction.begin()
CATransaction.setDisableActions(true)

let transition = CATransition()
transition.type = .moveIn
transition.subtype = .fromTop
transition.duration = 0.7

// UINavigationController#view直属のレイヤーオブジェクトに自前のアニメーションを加えることで動作させられる
navigationController.view.layer.add(transition, forKey: nil) 
// 標準のアニメーションをオフにして遷移vcのスタックにpushまたはpopする
navigationController.pushViewController(vc, animated: false)
もしくは 
navigationController.popViewControllerAnimated(false)

CATransaction.commit()


参考: ios - How to change the Push and Pop animations in a navigation based app - Stack Overflow

ただし、iOS7以上で提供された UIViewControllerAnimatedTransitioning で正式に遷移の挙動を定義し、置き換えるためのプロトコルが用意されています。コード量は増えるのですが、こちらの方が正式に提供できる方法と言えると思います。
参考: UIViewControllerAnimatedTransitioning 実装のサンプル


レイヤーバックビュー(Layer-Backed Views)のアニメーション化

UIView 直属のレイヤーオブジェクト(underlying layer)へのプロパティ変更に対する暗黙的アニメーションの発行はデフォルトで無効になっています。

・UIViewクラスはデフォルトでレイヤーアニメーションを無効にしています。
・レイヤーがレイヤーバックビュー(UIView直属のlayer)のオブジェクトの場合、ビューベースでのアニメーションインターフェイスを使用します。
iOSのビューでは常にレイヤーオブジェクトを持つため、UIViewはそのデータのほとんどをレイヤーオブジェクトから取得しています。その結果、レイヤーに加えた変更は、ビューにも自動的に反映されます。

iOSビューに属するレイヤーのアニメーション

ビューベースのアニメーションブロック内(UIView#animate(withDuration:animations:completion:))メソッドのブロックに記述することで、アニメーションは有効になります。

UIView.animate(duration: 1.0, animation:  {
    myView.layer.opacity = 0.0
})

公式ドキュメントのコードサンプルには、CABasicAnimation(明示的なアニメーション)の追加もUIViewのanimateブロックに加えていたのですが、検証したところブロック外でも動作するようです。

参考: ios - Use core animation on an Layer-Backed view - Stack Overflow

UIViewPropertyAnimator

UIViewのプロパティ変更をCATransaction風にアニメーション化するクラスメソッドが用意されていましたが、非推奨となっています。
iOS10以降はUIViewPropertyAnimatorのメソッドを使用します。

非推奨 class func beginAnimations(String?, context: UnsafeMutableRawPointer?)
非推奨 class func commitAnimations()

addAnimations(_:)

let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) {
  self.myView.backgroundColor = .green
}
animator.addAnimations {
  self.myView.layer.cornerRadius = 50.0
}
animator.startAnimation()

参考: Quick Guide To Property Animators



パースペクティブ(遠近表現)の追加

2つの変換行列

レイヤーとそのコンテンツを操作する2つ変換行列が transform プロパティと sublayerTransform プロパティです。

transform・・・デフォルトはCATransform3DIdentityに設定されています。レイヤーでの変換はanchorPointを基準にして行われます。基本的なレイヤー自身の変更(拡大・縮小・回転・位置の変更)はこのプロパティを使用できます。

sublayerTransform・・・デフォルトはCATransform3DIdentityに設定されています。サブレイヤーに対して表示効果を追加するため使用されます。コンテンツに遠近効果を追加するために最も一般的に使用されます。

レイヤーに遠近感を与える

・デフォルトでは「平行投影」で表示されており、zPosition の値が異なる同じサイズのレイヤーは、z軸上で離れていても、同じサイズに表示されます。(遠近感がない)。

・レイヤーの変換行列にパースペクティブ情報を含めると変更できます。(遠近がでる)

・シーンのパースペクティブを変更するには、表示されている子レイヤーを含む親レイヤーの sublayerTransform 行列を変更します。親レイヤーを変更することで、コードを簡素化しながら、すべての子レイヤーに同じパースペクティブ情報を適用できます。

let perspective = CATransform3D.identity
perspective.m34 = -1.0 / eyePosition
// 変換を親レイヤーに適用します。
parentLayer.sublayerTransform = CATransform3DRotate(perspective, angle * CGFloat.pi / 180, 0, 1, 0)

eyePosition(視点となるポジション) 変数には、z 軸上のレイヤーとの相対的な距離を指定します。レイヤーを期待する方向にするには正の値を指定します。大きな値を指定すると、シーンが平坦になり、小さな値を指定するとレイヤー間の視覚的な違いがより顕著になります。

eyePosition を小さな値にすると、視点とレイヤーが近づくため、遠近表現が大きなることがわかります。

f:id:snoozelag:20211215165523g:plain:w300

サンプルプロジェクトでは、パラメータを操作して確認できます。 → CoreAnimationZuroku

m34って?

先ほどのコードはドキュメントからの引用でしたがperspective.m34 = -1.0 / eyePosition行については何も説明がありません。気になる方もいるかと思います。 時間の都合上、完全解明という訳には行きませんでしたが、私が調べたことをまとめておきます。

f:id:snoozelag:20211215150327p:plain
座標の変換

f:id:snoozelag:20211215145304p:plain:w200
CATransform3D に変換行列が定義されています。

CATransform3DIdentityIdentity 変換は、任意の座標と乗算しても、そのまま同じ座標を返します。CALayer#transform のデフォルト値であり、何もしないフラットな値です。
・標準的な変換を行うために、この構造体のフィールドを直接変更する必要はありません。

とドキュメントにはあり「平行移動」「回転」「拡大縮小」といった標準的な操作にはそれぞれ、CATransform3DTranslate(::::)CATransform3DRotate(::::)CATransform3DScale(::::)コンポーネントが使用できます。

参考: Core Animation Basics

f:id:snoozelag:20211215155916p:plain:w200

例えば、このTranslate変換の図を見ながら変換行列のパラメータを指定する方法でやってみます。m41 = 100とすると、CATransform3DTranslateコンポーネントを使用せずともx軸上をオブジェクトの平行移動することが出来ます。

var transform = CATransform3DIdentity
transform.m41 = 100
layer.sublayerTransform = transform // サブレイヤーの x が 100pt移動して描画される


このようにそれぞれの行列成分に、操作される座標が定義されており、詳しい内部変換の話しを抜きにすると「パースペクティブを与える」表現を加える場合、標準的でない操作となるため直接的に変換行列の m34 に値を代入します。つまりはm34 が遠近表現を操作する行列成分として定義されている、ということになるのではと思います。
これは Core Animation に限った話ではなく、OpenGLなど3D表現を扱う技術でも、同様の定義がなされていることからこの界隈では共通なのだと思います。

参考: OpenGL Game Development Tutorials


レイヤーの3D表現、m34の作用について以下のリンクがとても参考になりました。 参考: milen.me — Core Animation's 3D Model
参考: Introduction to 3D drawing in Core Animation (Part 1) – Think And Build Github: ariok/TB_3DCoreAnimation
参考: perspective - meaning of m34 of CATransform3D - Stack Overflow


CATransformLayer vs CALayer#sublayerTransform


サブレイヤーを3D表現化するためには、上記のサンプルのように、CALayer#sublayerTransform = t と、もうひとつ CATransformLayer#transform = t を使用しても同じことが出来ます。どちらも同じように機能する場合、選択に迷うこともあるとあると思います。両者の実装でどう違いがあるのか CATransformLayer のドキュメントを読んでみます。
・サブレイヤーのみレンダリングする。
・レイヤー自身の2D画像処理を前提としたプロパティはオフされており無視される。
などの特徴があり、サブレイヤー群を3D表現で表示させる前提の場合には、通常の CALayer より余計な機能を持っている事になるので、それを省いたレイヤーという意図があると言えそうです。どちらを選択するかについて私は、親レイヤーに3D表現の前提があれば CATransformLayer を使う方が意図が明確になり間違いが少なくなるのかな、と思いました。



アニメーション編おわりに

アニメーションはUIにダイナミックなアクションが加わるので面白かったです。 3D表現であるとか、タイミングプロパティによる微調整だとかは、やはり話の詳細を理解したり詰めるのに苦労してしまいました。
ご覧頂きありがとうございました。
時間の限り検証を行いましたが何か間違い等ありましたら、ご連絡くだされば大変助かります。→@snoozelag
Core Animation その1(レイヤー編)の記事はこちら
サンプルコード集 CoreAnimationZuroku を公開しています。