深く知りたい Core Animation まとめ1(レイヤー編)【iOS / Swift】
CoreAnimation は、iOSとOS Xの両方で利用可能な「グラフィックスレンダリング」と「アニメーション」のインフラストラクチャです。
記事作成時の環境: Xcode13.0、iOS15、Swift 5
サンプルコード集 GitHub - snoozelag/CoreAnimationZuroku を公開しています。
後編の、深く知りたい Core Animation まとめ2(アニメーション編)はこちら
CoreAnimationを知るメリット
アニメーションをビューで実行するより細かく制御したい場合や、レイヤーの利用による描画パフォーマンスを向上したい場合に役に立ちます。また、ビューとレイヤーの関わり、UIフレームワークの中での役割を理解できます。
CoreAnimation の位置付け
AppKitとUIKitの下に位置し、CocoaとCocoa Touchのビューワークフローに統合されています。
CoreAnimationの中心は「レイヤー」
・「レイヤーオブジェクトは CoreAnimation で行うすべての中心にある」(Layer objects are at the heart of everything you do with CoreAnimation. )とあり、レイヤーを中心に操作します。
CoreAnimation は、もともと LayerKit という名前で、アニメーションは一側面に過ぎないとのこと。たしかに公式ドキュメントを読んでいく際にも「レイヤーの話」という心構えがある方が、理解ができるように思います。
レイヤーはビューではなくモデル
レイヤー(CALayer)は長方形で階層ツリーに配置できるなど、ビュー(UIView)とよく似た構成なので、ビューと混同してしまいそうですが、視覚的コンテンツの状態情報のみを扱います。
・レイヤーはビューではなくモデルの位置付けです。実際に表示を行うためにはビューオブジェクトを利用する必要があり、それ単体でビジュアルインターフェースを作成することはできないためです。画像コンテンツ、そのジオメトリ、および視覚的属性(visual attributes)に関する情報を管理します。
OS XとiOSで共同利用されるオブジェクトで、ビューが持つデータ周りを取り扱い、それをGPUと受け渡しする役割を担っています。
・レイヤーは、アプリ上の視覚的コンテンツ(ビューの描画そのものや、設定した画像)をキャプチャしビットマップにキャッシュする仕組みがあり、この仕組みをバッキングストア(backing store)と言います。レイヤーは、そのビットマップを取り巻くステート情報を管理するだけです。
・レイヤーはユーザー操作(タッチイベント、レスポンダチェーンへの参加)を処理しません。
・タッチイベントに応答できませんが、タッチイベントのポイントがレイヤーの境界内にあるかどうかを判断できるメソッド hitTest(_:)を持っています。
レイヤーとビューの関係
UIViewはlayerというプロパティを持ち、すべてのUIView
は対応するレイヤーオブジェクトを持っています。
・iOSのビューは、常にレイヤーが使用されており、レイヤーのサポートを受けています(この状態をレイヤーバックといい、iOSでは無効には出来ません)。iOSでは常にレイヤーバックであり、ビューはレイヤーの薄いラッパーです。ただし、ビューから操作を行えることは、レイヤーではなくビューで操作することをおすすめします。
・OSXのビューは、レイヤーバックにする場合、明示的に有効する必要があります。レイヤーバックにすると、ビューのコンテンツ描画とアニメーション化が簡単かつ効率的になり、高いフレームレートを維持できるため、ほとんどの場面で有効にするメリットがあります。
UIViewはレイヤーのラッパーということを確かめてみると、ビューと対応するレイヤーは、対応するプロパティが連動していることがわかります。
let hogeView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 10)) // view.backgroundColor と layer.backgroundColor を比較 hogeView.backgroundColor = UIColor.blue print(hogeView.layer.backgroundColor == UIColor.blue.cgColor) // true hogeView.layer.backgroundColor = UIColor.yellow.cgColor print(hogeView.backgroundColor == UIColor.yellow) // true // view.clipsToBounds と layer.masksToBounds を比較 hogeView.clipsToBounds = true print(hogeView.layer.masksToBounds == hogeView.clipsToBounds) // true hogeView.clipsToBounds = false print(hogeView.layer.masksToBounds == hogeView.clipsToBounds) // true hogeView.layer.masksToBounds = true print(hogeView.layer.masksToBounds == hogeView.clipsToBounds) // true hogeView.layer.masksToBounds = false print(hogeView.layer.masksToBounds == hogeView.clipsToBounds) // true // view.alpha と layer.opacity を比較 hogeView.alpha = 0.1 print(hogeView.alpha == CGFloat(hogeView.layer.opacity)) // true print(hogeView.layer.opacity) // 0.1 hogeView.layer.opacity = 0.3 print(hogeView.alpha == CGFloat(hogeView.layer.opacity)) // true print(hogeView.alpha) // 0.30000001192092896
ビジュアルだけに限定すると、UIViewのメソッドやプロパティはそのビューの直属のlayerオブジェクトの値を操作しているだけ、と捉えられます。
こちらの StackOverflow 回答でも言及されていますが、プロパティなどの呼び名は異なりますが
view.clipsToBounds
とview.layer.masksToBounds
なども、同じ効果であることが分かります。
役割の分担
・ビューの役割:イベントやタッチへの応答、レスポンダーチェーンへの参加など、これらのユーザー操作のアクションを処理します。
・レイヤーの役割:表示操作のみに関係します。ビジュアルコンテンツを管理し、そのスタイルと外観を変更するためのメソッドを提供します。視覚的コンテンツのレンダリング、レイアウト、アニメーションをサポートをします。
UIViewは対応するレイヤーオブジェクトを持つ
UIViewはレイヤーオブジェクトを初期値として持っており、UIViewが作成されるときlayerプロパティのオブジェクトも一緒に生成されています。UIView#layerプロパティのドキュメントには、UIViewが初期値で持っているレイヤーオブジェクトについて、以下のような説明があります。
・ビューオブジェクトに属するlayerはnilになることはありません。
CoreAnimationのドキュメントには、ビューに直属しているレイヤーのことをアンダーライングレイヤー(the underlying layer)という風に記載があります。
UIViewのlayerプロパティにはsetterはなく、それを別のレイヤーオブジェクトに置き換えることはできません。もちろん、プロパティの操作・変更は可能です。
レイヤーのdelegate
はそのビューオブジェクトに設定されており、これを変更しないでくださいと記載されています。
・ビュー直属のレイヤーのクラス(サブタイプ)は変更できます。ただし作成された後はクラスを変更できません。
■ 初期値のレイヤーオブジェクトをサブタイプに変更
class SomeView: UIView { // ビューが作成される時の、ビュー直属のレイヤーのクラスのサブタイプを指定 override class var layerClass: AnyClass { return SubtypeLayer.self } }
ここで、クラス指定できるのは後述のフレームワークで提供されているものに限り、自前のカスタムレイヤークラスなどを指定できないことに注意してください。
・対応するビューを持たないレイヤーをスタンドアロンレイヤーと言います。用いられるケースとしては、他のレイヤーオブジェクト内に組み入れる(addSublayerする)サブレイヤーです。パフォーマンスを最適化する目的で、ビューを必要としない場合に利用します。
サブタイプのクラスは提供する機能がそれぞれに異なっており、目的に合わせて利用します。CATextLayer、CAShapeLayer、CAGradientLayer、CAEmitterLayer、CAScrollLayer、CATransformLayer、CATiledLayer、CATiledLayer、CAReplicatorLayer、CAMetalLayer があります。
紙面の都合で、それぞれの挙動を確認するためのコードはサンプルコード集に記載しました。
用語
たとえば、CALayerのドキュメントを読むとbacking store
、isGeometryFlippedプロパティのドキュメントではlayer-backed view
などが既知のものといった扱い使用されており、ドキュメントを読む上で押さえた方が良いなと思った用語もまとめておきます。
*LayerKit・・・CoreAnimationの昔の呼び名。
*バッキングストア(backing store)・・・ビューを通じた視覚的変更などを、レイヤーがキャプチャしビットマップにキャッシュする仕組み
*レイヤーバック(layer backed)・・・レイヤー(CALayer)によってサポートされること。ビジュアルの処理でレイヤーを使用すること。システムがビューと連携されたレイヤーオブジェクトを作成し、そのレイヤーと同期させる仕組みのこと。すべてのiOS(UIKit)ビューはレイヤーバックです。OSXでこの仕組みを利用する場合っは、ビューのレイヤーサポートを明示的に有効にする必要があります。
*レイヤーバックビュー(layer-backed view)・・・レイヤーのサポートを受けるビューのこと。ビューの作成と同時にレイヤーが作成され、そのビューとレイヤーはシステムによって同期されます。
*アンダーライングレイヤー(underlying layer object)・・・UIView(システムのレイヤーサポートを有効にした状態のビュー)で、一緒に生成されるレイヤーオブジェクト。ビューに関連づけられたレイヤー。レイヤーバックビューが持つレイヤー。
*レイヤーホスティングビュー(layer-hosting view)・・・レイヤーオブジェクトをマニュアルで管理・提供するビューのこと。OSXでのみこの状態のビューが存在します。この場合、AppKitはレイヤーの管理に関与しないため、ビューの変更に応じたシステムによるレイヤーの変更は行われません。
*スタンドアロンレイヤーオブジェクト(standalone layer objects)・・・レイヤーバックビューのようにシステム的に対応するビューを持たない、個別に作成したレイヤーオブジェクトのこと。一般的には、他のレイヤーオブジェクト内に組み入れて使用する、サブレイヤーになります。パフォーマンスを最適化する目的で、ビューを必要としない場合に利用します。
レイヤーの座標系
レイヤーでのコンテンツの配置の指定方法について、まとめていきたいと思います。
レイヤーで使用される2種類の座標系
■ ポイントベース座標系(point-based coordinate systems)
レイヤーのディスプレイ上のサイズと位置をCGRect
、CGPoint
、CGSize
で指定する座標系です。UIView でもお馴染みの bounds、frame プロパティや、position
プロパティの指定で使用される座標系です。
frame
プロパティはbounds
、position
プロパティの派生値となっており、スーパーレイヤーの座標空間上での位置を指定する時は定義上position
プロパティを用いるようです。ただしposition
プロパティが返す位置は(anchorPoint
がデフォルトの (0.5, 0.5)
のとき)レイヤーの両端から真ん中の位置を指します。
position
はUIViewでいうところのcenter プロパティと対応しているようです。私が検証したところview.center
とlayer.position
は同じジオメトリを指しますので、これも呼称が違うだけで内容は同じもののようです。
配置には慣れた frameプロパティを利用してレイヤー位置とサイズの指定することもできます。
■ ユニット座標系(unit coordinate systems)
・座標変換(特に回転など)の軸となる位置を操作する anchorPointプロパティで用いられる座標系です。座標の範囲は 0.0〜1.0 で指定します。x軸上で、左端が座標 0.0、右端が座標 1.0、y軸では上端が座標 0.0、右端が座標 1.0 になります。anchorPoint
を操作した場合、position
、transform
プロパティが最も顕著な影響を受けます。
anchorPoint
というのは自身の領域の中でどの位置を軸とするかを決定するプロパティで、座標変換の基準として使用されます。デフォルトは CGPoint(x: 0.5, y: 0.5) の比率が指す場所で、ちょうど独楽(こま)の軸を指す位置のように長方形の真ん中にあります。ポイントベースでその値を取得するにはposition
プロパティで取得できます。
座標変換の基準となるanchorPoint
を変更した時の各プロパティの影響をしめす図が、ドキュメントには記載されています。しかし、この図を見ると、レイヤー自身の位置は変わらずにposition
の指す場所が変更されているように読めます。
理屈としてはたしかにそうなのですが、実際にコードで実行すると挙動は異なります。
次のようにanchorPoint
をデフォルト値の(0.5, 0.5)
から(1.0, 1.0)
に変更してみます。
図では、アンカーポイントを変更したときframe
は維持された状態ではなくlayer.position
のジオメトリの方が基準となっており維持されます。
検証を行った内容は以下で、print()の出力をコメント文として記載しています。
let subView = UIView() view.addSubview(subView) subView.frame = CGRect(x:0, y:0, width:300, height:200) print(subView.center) // 出力 (150.0, 100.0) print(subView.layer.position) // 出力 (150.0, 100.0) print(subView.frame) // 出力 (0.0, 0.0, 300.0, 200.0) subView.layer.anchorPoint = CGPoint(x: 1.0, y: 1.0) // アンカーポイントを変更 print(subView.center) // 出力 (150.0, 100.0) print(subView.layer.position) // 出力 (150.0, 100.0) ←positionの位置が維持され、 print(subView.frame) // 出力 (-150.0, -100.0, 300.0, 200.0) ←frameが移動される subView.center = view.center
frame プロパティのドキュメントを見ると「frame
プロパティはbounds
、anchorPoint
プロパティから派生した値である」とあり、派生値であれば position
の位置の保持が優先的になっていそうです。
回転アニメーションで、軸となるanchorPoint
のみを変更
しframe
は移動させたくないような場面があるかもしれません。その場合には、次のような回答が参考に出来そうでした。
回転していないビューでのシンプルな解決案
UIViewのアニメーションの基準点を変更する - エンジニアうまの日記
回転を含む解決案
iphone - Changing my CALayer's anchorPoint moves the view - Stack Overflow
3次元での操作
操作関連プロパティをUIViewと比較した時に、特徴としてはzPosition
を持つことが挙げられると思います。「3D空間に配置された2Dの外面」とあり、例えるならば、一枚板の看板(レイヤー)が奥行きもある三次元の空間上に配置できるイメージでしょうか。3枚のレイヤー(赤、緑、青)のzPosition
をそれぞれ0、20、40と設定して奥行き&回転させた図
通常、レイヤーもUIViewと同じく「追加した順番」で前後の関係を持ちます。
同じ階層上にあるレイヤー同士は「zPosition」を操作することで「追加した順番」を超えて前後の関係を入れ替えることができます。
次のアニメーションは青色のレイヤーのzPosition
を徐々に大きくして、他のレイヤーより全面へ移動している様子です。zPosition
の操作で前後関係を操作できるのは同じレイヤー階層に所属している必要があります。
アニメーション編で関連記事を紹介しています。
レイヤーツリー(Layer Trees)とは
CoreAnimationを使用するアプリでは、次の3セットのレイヤーオブジェクトが存在します。
*レイヤーツリーまたは「モデルレイヤーツリー(model layer tree)」・・・アプリのプログラマが通常扱っているレイヤーオブジェクトを含むツリーで、アニメーションオブジェクト与えて操作したり、レイヤーのプロパティを変更するときに使用しているのは、このモデルレイヤーツリーのオブジェクトになります。
*プレゼンテーションツリー(presentation tree)・・・このツリーのオブジェクトは、実行中のアニメーションの現在値が含まれています。モデルレイヤーのオブジェクトからpresentation()にアクセスすることで取得が可能です。アニメーションの進行中、その瞬間に画面に表示されるレイヤーの値が取得できます。
このツリーのオブジェクトは絶対に変更しないでください、とのこと。つまり、読み取り専用です。逆に、プレゼンテーションレイヤーのオブジェクトからモデルレイヤーのオブジェクトをmodel()で取得することもできます。アニメーションの進行中にのみアクセスする必要があります。その値から新しいアニメーションオブジェクトを作成するのに使用したり、アニメーション中に削除した時にモデルレイヤーツリー最終フレームを反映したりするのに使用します。
*レンダーツリー(render tree)・・・CoreAnimation 専用でアクセス不可。アニメーション実行時のレンダリング処理に用いられています。
CALayer#add(_:forKey:)の説明には、
・指定したアニメーションオブジェクトをレイヤーのレンダーツリーに追加する
・アニメーションオブジェクトはレンダーツリーにコピーされるので、その後のモデルレイヤープロパティ変更はレンダーツリーに反映されない
といった記載があります。アニメーションをレイヤーに追加するメソッドは、レンダーツリーへの働きかけであることがわかります。
対称的な操作であるアニメーションのremove系メソッドは、このツリーセットからアニメーションオブジェクトを削除する、意味になるといったことが読み取れそうです。
・iOSのようにすべてレイヤーバックビューの場合、各ツリー(つまり上記3セットのレイヤーツリー)の初期構造はビュー階層の構造と正確に一致します。
プロパティを変更するためにアクセスするレイヤーオブジェクトと、アニメーション中の値を持つレイヤーオブジェクトは異なる、ということがわかります。
レイヤーのコンテンツを設定する3つの方法
(1) 画像をcontents
プロパティに割り当てる
・コンテンツを全くまたはほとんど変更しない場合に適切
(2) デリゲートオブジェクトにコンテンツを描画させる
・コンテンツが定期的に変更される可能性があり、ビューなどの外部オブジェクトによって提供される可能性があるレイヤーコンテンツ
display(_:)またはdraw(_:in:)を実装します。(両方を実装している場合はdisplay(:_)のみが呼び出されます)
コードサンプルは、各デリゲートメソッドのドキュメントに記載があります。
UIViewのlayerのdelegateはそのUIViewに設定されていますので変更してはいけません、とUIViewのドキュメントにあります。
UIViewはレイヤーバックビューです。UIViewはCALayerのラッパーであり、UIViewとそのlayer
(レイヤーバックビューとアンダーライングレイヤー)のコンテンツ提供関係はこれにあたるためです。
レイヤーバックビューで描画を行う場合、UIViewのdraw(_:) を使用します。
(3) サブクラスを定義し描画メソッドをオーバーライドしてコンテンツを描画する
・サブクラスを作成する必要がある場合、
・描画を変更したい場合に適切
描画メソッドはdisplay()または
draw(in: CGContext)になります。
レイヤーの外観を調整する
画像の位置、コンテンツグラビティ
・contentsGravityプロパティを操作すると、レイヤーの領域に対して、contents(CGImage)の配置を変更できます。右寄せにしたり、画像のリサイズ(アスペクト比を維持する/しない)といった表示を選択できます。デフォルト値はresize
(アスペクト比を維持しない唯一のモード)であり、レイヤーのboundsぴったりに画像が拡大縮小されています。その他の選択肢についてはContents Gravity Valuesに定数が定義されています。
定数は次の2つのカテゴリに分類されます。
(1) 位置ベースの定数・・・画像を拡大縮小せずに、レイヤーのboundsの特定のエッジまたはコーナーに画像を固定
(2) スケーリングベースの定数・・・アスペクト比を保持するものと保持しないもの、画像をbounds
に引き伸ばします。
UIView.ContentModeとほぼ同じになってます。
・iOSでcontentGravityを操作する場合にはisGeometryFlippedをtrue
に変更します。そうしないとcontentsGravity
にtop
を指定したときに画面の下に配置され、縦の配置に関して操作と逆に表示されます。CoreAnimation は、OS XとiOSの両方で使用されますが、それぞれのOSで原点座標の位置が異なる(LLO(Lower Left Origin)とULO(Upper Left Origin))ことに関係しています。 contentsGravityのドキュメントに記載があります。
参考: add UIImage in CALayer - Stack Overflow
layer.contentsGravity = .top layer.isGeometryFlipped = true
デバイス画面のピクセル密度に合わせてcontentsScaleを設定する
・レイヤーは、デバイス画面の解像度を知りません。画像をcontents
プロパティに割り当てる場合、contentsScale
プロパティを適切な値に設定し、画像の解像度を CoreAnimation に通知する必要があります。
・contentScaleは、論理座標空間(pt - ポイント)と物理座標空間(px - ピクセル)のマッピングを定義しています。このプロパティのデフォルト値は1.0
です。
デバイスの画面に適した画像リソースの用意と、pt-px間の変換設定の話になります。
最近では、さまざまなデバイスの解像度を想定して、1x
、@2x
、@3x
それぞれのピクセルサイズの画像リソースをプロジェクトに含めることが普通だと思います。
アプリでレイヤーのbounds
が50x50 pt
の表示をする必要があるとします。この時、画像表示がボヤけないようにリソースとして用意する@2x
デバイス用の画像は100x100 px
、@3x
デバイス用の画像は150x150 px
が必要になります。
下のようなコードの挙動を確認しておきたいと思います。
layer.contents = UIImage(named: "sample")?.cgImage layer.contentsScale = UIScreen.main.scale // 2.0
UIImage(named: "sample")
の部分で返されるのは、@2x
のRetinaデバイスでアプリを動作させたとして@2x
画像リソースです。この時contentsScale
のデフォルトは1.0
であり、設定しない場合そのままですのでUIScreen.main.scaleを利用して、そのデバイスの画面密度を設定してあげる必要があります。
もし、contentsScale
が1.0
のままで、contents
に@2x
用の画像100x100 px
が割り当てられた場合、そのままビットマップで100x100 pt(Retinaでは200 x 200pxに相当)
と解釈・変換されるため、Retinaデバイス用に指定したレイヤーで想定した50x50 pt(Retinaでは100 x 100pxに相当)
の領域からは、大きくはみ出して表示されます(そして粗く見えます)。
角丸の設定 cornerRadius
cornerRadiusに設定する値は角丸の半径(radias)です。
・cornerRadius
は、レイヤーのbackgroundColor
やborder
の描画に常に影響を与えます。(backgroundColorとborderが描画される際に角丸効果が加わる)
・masksToBoundsをtrue
に変更すると、レイヤーの境界に一致し、角丸効果を含む暗黙のクリッピングマスクが作成されます。(contents
プロパティの画像に角丸が反映されます。)
layer.cornerRadius = 5.0 layer.masksToBounds = true // contents に画像を設定している場合、デフォルトの false から true に変更する layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // 上辺だけ角丸
cornerRadiusの値はCALayerの内部的ではUIBezierPath#init(roundedRect:cornerRadius:)のような実装のメソッドに渡されて描画されているように想像できそうです。(*UIBezierPathはUIKitの実装なので、層の順序が異なりますが)
次は、レイヤーのコンテンツのレンダリング順序を示した図ですが、それぞれのプロパティの反映イメージを書き加えてみました。
シャドウ
・デフォルトではshadowOffsetはCGSize(width: 0, height: -3)
であり、若干上目でほぼレイヤーの真下(重なるように、z軸で裏側)に配置されます。
・影はレイヤーのboundsの大きさに合わせて、レイヤーの外側に描画されます。
let view = UIView(frame: CGRect(x: 0, y:0, width: 200, height: 150) view.layer.backgroundColor = UIColor.blue.cgColor view.layer.shadowOffset = CGSize(width: -20, height: -20) // 影の位置(この値では、左上に移動) view.layer.shadowRadius = 20 // 影の大きさ view.layer.shadowColor = UIColor.black.cgColor // 影の色 view.layer.shadowOpacity = 0.5 // 影の透明度
・シャドウを適用するにあたってよく発生する問題が2つあります。
(問題1) masksToBounds
プロパティをtrue
にしたら、シャドウ効果がクリップされて表示されなくなった。
(問題2) contents
に透明を含む画像が設定されているとき、画像周辺でシャドウ効果が途切れてしまった。
この(問題1)の解決策がCoreAnimation公式ドキュメントに記載されています。
(解決策)シャドウが必要でmasksToBounds
もtrue
にしたい場合(つまり、角丸表示が必要な場合)は、レイヤーを2つ使用します。
コンテンツ用のレイヤーのmasksToBounds
を有効にします。シャドウ用レイヤーを用意し、シャドウを有効にします。そこにコンテンツ用レイヤーをaddSublayer()
します。各レイヤーを同じサイズにします。
具体的にコードに起こしたものが以下になります。
let cornerRadius: CGFloat = 20 // レイヤー1(コンテンツを含み、masksToBoundsが有効) let contentsLayer = CALayer() contentsLayer.cornerRadius = cornerRadius contentsLayer.masksToBounds = true contentsLayer.contents = UIImage(named: "imageName")?.cgImage contentsLayer.contentsScale = UIScreen.main.scale // レイヤー2(シャドウ効果を有効。) let view = UIView(frame: CGRect(x:0, y:0, width:300, height:200)) view.layer.cornerRadius = cornerRadius view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOffset = .zero view.layer.shadowOpacity = 0.5 view.layer.shadowRadius = 10 view.layer.addSublayer(imageLayer) contentsLayer.frame = view.bounds // レイヤーのサイズを合わせる
レイヤー編おわりに
CoreAnimation の話を深掘りしてみると、UIViewとCALayerといった当たり前に使ってきたクラスの関連性が以前より、よく見えてきました。
後編のアニメーション編に続きます。
この記事がお役に立ちましたら幸いです。何か間違いがありましたらお気軽にご連絡くだされば助かります。→@snoozelag
サンプルコード集 GitHub - snoozelag/CoreAnimationZuroku を公開しています。