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 を公開しています。

深く知りたい Core Animation まとめ1(レイヤー編)【iOS / Swift】

f:id:snoozelag:20211122150303j:plain

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

記事作成時の環境: Xcode13.0、iOS15、Swift 5

サンプルコード集 GitHub - snoozelag/CoreAnimationZuroku を公開しています。
後編の、深く知りたい Core Animation まとめ2(アニメーション編)はこちら

CoreAnimationを知るメリット

アニメーションをビューで実行するより細かく制御したい場合や、レイヤーの利用による描画パフォーマンスを向上したい場合に役に立ちます。また、ビューとレイヤーの関わり、UIフレームワークの中での役割を理解できます。



CoreAnimation の位置付け

AppKitとUIKitの下に位置し、CocoaCocoa Touchのビューワークフローに統合されています。

f:id:snoozelag:20211027113228p:plain:w300


CoreAnimationの中心は「レイヤー」

・「レイヤーオブジェクトは CoreAnimation で行うすべての中心にある」(Layer objects are at the heart of everything you do with CoreAnimation. )とあり、レイヤーを中心に操作します。

CoreAnimation は、もともと LayerKit という名前で、アニメーションは一側面に過ぎないとのこと。たしかに公式ドキュメントを読んでいく際にも「レイヤーの話」という心構えがある方が、理解ができるように思います。


レイヤーはビューではなくモデル

レイヤー(CALayer)は長方形で階層ツリーに配置できるなど、ビュー(UIView)とよく似た構成なので、ビューと混同してしまいそうですが、視覚的コンテンツの状態情報のみを扱います。

・レイヤーはビューではなくモデルの位置付けです。実際に表示を行うためにはビューオブジェクトを利用する必要があり、それ単体でビジュアルインターフェースを作成することはできないためです。画像コンテンツ、そのジオメトリ、および視覚的属性(visual attributes)に関する情報を管理します。

OS XiOSで共同利用されるオブジェクトで、ビューが持つデータ周りを取り扱い、それを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.clipsToBoundsview.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 storeisGeometryFlippedプロパティのドキュメントでは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)
レイヤーのディスプレイ上のサイズと位置をCGRectCGPointCGSizeで指定する座標系です。UIView でもお馴染みの boundsframe プロパティや、positionプロパティの指定で使用される座標系です。

frameプロパティはboundspositionプロパティの派生値となっており、スーパーレイヤーの座標空間上での位置を指定する時は定義上positionプロパティを用いるようです。ただしpositionプロパティが返す位置は(anchorPointがデフォルトの (0.5, 0.5)のとき)レイヤーの両端から真ん中の位置を指します。

positionはUIViewでいうところのcenter プロパティと対応しているようです。私が検証したところview.centerlayer.positionは同じジオメトリを指しますので、これも呼称が違うだけで内容は同じもののようです
配置には慣れた frameプロパティを利用してレイヤー位置とサイズの指定することもできます。

f:id:snoozelag:20211123002921p:plain:w600
ポイントベース座標系

ユニット座標系(unit coordinate systems)

・座標変換(特に回転など)の軸となる位置を操作する anchorPointプロパティで用いられる座標系です。座標の範囲は 0.0〜1.0 で指定します。x軸上で、左端が座標 0.0、右端が座標 1.0、y軸では上端が座標 0.0、右端が座標 1.0 になります。
anchorPointを操作した場合、positiontransformプロパティが最も顕著な影響を受けます。

f:id:snoozelag:20211123004737p:plain:w600
ユニット座標系

anchorPointというのは自身の領域の中でどの位置を軸とするかを決定するプロパティで、座標変換の基準として使用されます。デフォルトは CGPoint(x: 0.5, y: 0.5) の比率が指す場所で、ちょうど独楽(こま)の軸を指す位置のように長方形の真ん中にあります。ポイントベースでその値を取得するにはpositionプロパティで取得できます。

座標変換の基準となるanchorPointを変更した時の各プロパティの影響をしめす図が、ドキュメントには記載されています。しかし、この図を見ると、レイヤー自身の位置は変わらずにpositionの指す場所が変更されているように読めます。

f:id:snoozelag:20211123011502p:plain:w600
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プロパティはboundsanchorPointプロパティから派生した値である」とあり、派生値であれば 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と設定して奥行き&回転させた図

f:id:snoozelag:20211124150114p:plain:w400

通常、レイヤーもUIViewと同じく「追加した順番」で前後の関係を持ちます。 同じ階層上にあるレイヤー同士は「zPosition」を操作することで「追加した順番」を超えて前後の関係を入れ替えることができます。
次のアニメーションは青色のレイヤーのzPositionを徐々に大きくして、他のレイヤーより全面へ移動している様子です。zPositionの操作で前後関係を操作できるのは同じレイヤー階層に所属している必要があります。

f:id:snoozelag:20211124154803g:plain:w300

アニメーション編で関連記事を紹介しています。

レイヤーツリー(Layer Trees)とは


CoreAnimationを使用するアプリでは、次の3セットのレイヤーオブジェクトが存在します。

レイヤーツリーまたは「デルレイヤーツリー(model layer tree)」・・・アプリのプログラマが通常扱っているレイヤーオブジェクトを含むツリーで、アニメーションオブジェクト与えて操作したり、レイヤーのプロパティを変更するときに使用しているのは、このモデルレイヤーツリーのオブジェクトになります。

プレゼンテーションツリー(presentation tree)・・・このツリーのオブジェクトは、実行中のアニメーションの現在値が含まれています。モデルレイヤーのオブジェクトからpresentation()にアクセスすることで取得が可能です。アニメーションの進行中、その瞬間に画面に表示されるレイヤーの値が取得できます。

このツリーのオブジェクトは絶対に変更しないでください、とのこと。つまり、読み取り専用です。逆に、プレゼンテーションレイヤーのオブジェクトからモデルレイヤーのオブジェクトをmodel()で取得することもできます。アニメーションの進行中にのみアクセスする必要があります。その値から新しいアニメーションオブジェクトを作成するのに使用したり、アニメーション中に削除した時にモデルレイヤーツリー最終フレームを反映したりするのに使用します。

レンダーツリー(render tree)・・・CoreAnimation 専用でアクセス不可。アニメーション実行時のレンダリング処理に用いられています。

CALayer#add(_:forKey:)の説明には、

・指定したアニメーションオブジェクトをレイヤーのレンダーツリーに追加する
・アニメーションオブジェクトはレンダーツリーにコピーされるので、その後のモデルレイヤープロパティ変更はレンダーツリーに反映されない

といった記載があります。アニメーションをレイヤーに追加するメソッドは、レンダーツリーへの働きかけであることがわかります。
対称的な操作であるアニメーションのremove系メソッドは、このツリーセットからアニメーションオブジェクトを削除する、意味になるといったことが読み取れそうです。

iOSのようにすべてレイヤーバックビューの場合、各ツリー(つまり上記3セットのレイヤーツリー)の初期構造はビュー階層の構造と正確に一致します。

f:id:snoozelag:20211123015227p:plain:w600

f:id:snoozelag:20211123015629p:plain:w600

プロパティを変更するためにアクセスするレイヤーオブジェクトと、アニメーション中の値を持つレイヤーオブジェクトは異なる、ということがわかります。

レイヤーのコンテンツを設定する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を操作する場合にはisGeometryFlippedtrueに変更します。そうしないとcontentsGravitytopを指定したときに画面の下に配置され、縦の配置に関して操作と逆に表示されます。CoreAnimation は、OS XiOSの両方で使用されますが、それぞれの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それぞれのピクセルサイズの画像リソースをプロジェクトに含めることが普通だと思います。

アプリでレイヤーのbounds50x50 ptの表示をする必要があるとします。この時、画像表示がボヤけないようにリソースとして用意する@2xバイス用の画像は100x100 px@3xバイス用の画像は150x150 pxが必要になります。

下のようなコードの挙動を確認しておきたいと思います。

layer.contents = UIImage(named: "sample")?.cgImage
layer.contentsScale = UIScreen.main.scale // 2.0

UIImage(named: "sample")の部分で返されるのは、@2xRetinaバイスでアプリを動作させたとして@2x画像リソースです。この時contentsScaleのデフォルトは1.0であり、設定しない場合そのままですのでUIScreen.main.scaleを利用して、そのデバイスの画面密度を設定してあげる必要があります。

もし、contentsScale1.0のままで、contents@2x用の画像100x100 pxが割り当てられた場合、そのままビットマップで100x100 pt(Retinaでは200 x 200pxに相当)と解釈・変換されるため、Retinaバイス用に指定したレイヤーで想定した50x50 pt(Retinaでは100 x 100pxに相当)の領域からは、大きくはみ出して表示されます(そして粗く見えます)。

角丸の設定 cornerRadius

cornerRadiusに設定する値は角丸の半径(radias)です。

cornerRadiusは、レイヤーのbackgroundColorborderの描画に常に影響を与えます。(backgroundColorとborderが描画される際に角丸効果が加わる)

masksToBoundstrueに変更すると、レイヤーの境界に一致し、角丸効果を含む暗黙のクリッピングマスクが作成されます。(contentsプロパティの画像に角丸が反映されます。)

layer.cornerRadius = 5.0
layer.masksToBounds = true // contents に画像を設定している場合、デフォルトの false から true に変更する
layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // 上辺だけ角丸

cornerRadiusの値はCALayerの内部的ではUIBezierPath#init(roundedRect:cornerRadius:)ような実装のメソッドに渡されて描画されているように想像できそうです。(*UIBezierPathはUIKitの実装なので、層の順序が異なりますが)

次は、レイヤーのコンテンツのレンダリング順序を示した図ですが、それぞれのプロパティの反映イメージを書き加えてみました。

f:id:snoozelag:20211123155129p:plain:w400
レンダリングへの反映イメージ

シャドウ

・デフォルトではshadowOffsetCGSize(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 // 影の透明度

f:id:snoozelag:20211123153441p:plain:w300

・シャドウを適用するにあたってよく発生する問題が2つあります。

(問題1) masksToBoundsプロパティをtrueにしたら、シャドウ効果がクリップされて表示されなくなった。
(問題2) contentsに透明を含む画像が設定されているとき、画像周辺でシャドウ効果が途切れてしまった。

この(問題1)の解決策がCoreAnimation公式ドキュメントに記載されています。

(解決策)シャドウが必要でmasksToBoundstrueにしたい場合(つまり、角丸表示が必要な場合)は、レイヤーを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 // レイヤーのサイズを合わせる


f:id:snoozelag:20211123163835p:plain:w400

レイヤー編おわりに

CoreAnimation の話を深掘りしてみると、UIViewとCALayerといった当たり前に使ってきたクラスの関連性が以前より、よく見えてきました。

後編のアニメーション編に続きます。

この記事がお役に立ちましたら幸いです。何か間違いがありましたらお気軽にご連絡くだされば助かります。→@snoozelag
サンプルコード集 GitHub - snoozelag/CoreAnimationZuroku を公開しています。

詳しく知りたい Core Graphics まとめ【 iOS / Swift / UIKit / SwiftUI】

f:id:snoozelag:20211026020947j:plain

Core Graphics フレームワークでは Quartz2D という描画エンジンが使用されています。

Core Graphics (Quartz2D) APIを理解する上でのiOS環境での利用方法とその要点、サンプルコードを記載します。

当記事のサンプルコード集 → snoozelag/QuartzZuroku にまとめています。

記事作成時の環境: Xcode13.0、iOS15、Swift 5

ペインタモデル (painter's model)

Quartz 2Dの画像処理は、ペインタモデルを採用しています。ペインタモデルとは、描画操作ごとに、描画が上書きされていく方式になります。

・Quartz2Dでは、順次行う描画操作ごとに「ペイント(ペンキ)」の層が出力され、「キャンバス」に重ねられます。通常この「キャンバス」は『ページ』と呼ばれます。

・ページ上のペイントは、さらにペイントを重ねることのみで変更が可能、というプリミティブな方式です。

例えば、次の (1)と(2)の例をあげます。グラフィックスコンテキストに対するドローイングの操作内容は同じですが、実行した順序のみが異なります。

// 例(1)
override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let size = CGSize(width: 100, height: 100)
    // 青い長方形(ライン)を描画
    context.setStrokeColor(UIColor.blue.cgColor)
    context.addRect(CGRect(origin: CGPoint(x: 70, y: 70), size: size))
    context.strokePath()
    // 赤い長方形(塗りつぶし)を描画
    context.setFillColor(UIColor.red.cgColor)
    context.addRect(CGRect(origin: CGPoint(x: 80, y: 80), size: size))
    context.fillPath()
}
// 例(2)
override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let size = CGSize(width: 100, height: 100)
    // 赤い長方形(塗りつぶし)を描画
    context.setFillColor(UIColor.red.cgColor)
    context.addRect(CGRect(origin: CGPoint(x: 80, y: 80), size: size))
    context.fillPath()
    // 青い長方形(ライン)を描画
    context.setStrokeColor(UIColor.blue.cgColor)
    context.addRect(CGRect(origin: CGPoint(x: 70, y: 70), size: size))
    context.strokePath()
}

(1)の結果

青い長方形(ライン)は、後で実行した赤い長方形(塗りつぶし)で上書きされました。

(2)の結果

赤い長方形(塗りつぶし)は、後で実行した青い長方形(ライン)で上書きされました。

・ペインタモデルでは、ドローイングの順序が重要になります。

ペインタモデルは後のペイントオペレーションが、既存のペイントを上書きしていることがわかります。また、ペイントの結果を入れ替えるようなメソッドも見当たりません。

ペインターモデルは、Adobe社が1984年に発表したページ記述言語である PostScript で、またPDFの仕様でも採用されている基本的な画像描画モデルです。

グラフィックスコンテキスト (Graphics Context)

グラフィックスコンテキストとは描画先であり、Quartzが描画を画像としてデバイスに出力するための情報をカプセル化したオブジェクトです。

CGContextRef (Objective-C) CGContext (Swift) として定義されています。

・不透明(OPAQUE)型です。このモジュールの利用者(Quartzを使う私たち)には、内部の詳細(具体的な型や実装)が表示されない型のことを指しています。

・描画パラメータ、ページ上のすべてのペイントオブジェクト、が含まれます。

・使用する種類のデバイス固有の特性が含まれます。なので、同じ Quartz 描画ルーチン操作を、別のグラフィックスコンテキスト向けに出力することが出来ます。

・出力デバイスには、PDFファイル、ビットマップ、ウィンドウ、レイヤーコンテキスト、プリンタ等があります。

iOS: UIKitでのグラフィックスコンテキストの取得

iOS(UIKit)では、あらゆる技術も最終的な画面への描画を、UIViewクラスまたはそのサブクラスのインスタンスにて行っており、UI描画用のそのグラフィックスコンテキストは UIViewのdraw(_:)メソッドの実装の中で取得できます。

  1. UIViewのサブクラスを作成し draw(_:) を実装します。
  2. このメソッド内で UIGraphicsGetCurrentContext() をコールすることで、描画用のグラフィックスコンテキストを取得することが出来ます。
  3. 取得したグラフィックスコンテキストを用いて、描画操作を行います。
// UIView#draw(_:)内で描画用のグラフィックスコンテキストが取得できます。
class SomeView: UIView {
    override func draw(_ rect: CGRect) {
        let context = UIGraphicsGetCurrentContext()!
        // contextに対する描画操作が以下に続く…
    }
}

・各UIViewオブジェクトは、draw(_:) がコールされる前にグラフィックスコンテキストを作成し、カレント(現在)のコンテキストとして設定します。

・このコンテキストは draw(_:) が呼ばれている間だけ存在します。

つまりdraw(_:) が呼ばれる度に、グラフィックスコンテキストは新たに作られるのでUIGraphicsGetCurrentContext()で取れる参照は毎回異なり、前回の draw(_:) 時のグラフィックスステート等を引きつがない、と考えて良さそうです。(手元で簡単に検証済み)

Swift3以降のCGContext

Core Graphics を含む C言語のライブラリ・フレームワークは、Swift2以前ではグローバルアクセスのAPIデザインでしたが、Swift3で言語に馴染むよう現在のメンバ変数・関数でのアクセスに変更(Swift 3 SE-0044 Import as member)されています。

// Swift2 before
let context = UIGraphicsGetCurrentContext()!
CGContextSetLineWidth(2)
CGContextAddRect(context, CGRect(x: 0, y: 0, width: 100, height: 100))
CGContextFillPath()

// Swift3 after
let context = UIGraphicsGetCurrentContext()!
context.setLineWidth(2)
context.addRect(CGRect(x: 0, y: 0, width: 100, height: 100))
context.fillPath()

CGContextに追加したパスは var path: CGPath? で取得できますが、Quartz には直接アクセスできる個々のパスやサブパスのオブジェクト表現はありません。

C言語由来のAPIということもあり、context を用いて手続き型のように描画関数をコールし、図形を組み立てます。

グラフィックスステート (Graphics States)

グラフィックスステートとは、パスの描画ルーチンが引数にとるパラメータ群の状態のことです。例えば、strokeColor (ライン色)lineWidth (線の幅)といったパラメータになり、iOSではCGContextの参照などを通じて設定できます。

Quartzは、現在のグラフィックスステートのパラメータに応じたペイントを行います。

・グラフィックスコンテキストに、グラフィクスステートのスタックが含まれています。グラフィックスコンテキストが作成された時点では、スタックは空です。

・描画ルーチンは、グラフィックスステートを参照し、パスをどのようにレンダリングするかを決定します。

・描画ルーチンの引数として取られる代表的なパラメータ Color: fill and stroke settings(線や塗りつぶしの色)Line: width, join, cap, dash, miter limit(線の描画設定)Clipping area(マスクエリア) などです。

Quartz 2D Programming Guide / Table 1-1 Parameters that are associated with the graphics state

・形状を描くのに使用される、カレントパスのデータはステートに含まれません。ページ上の特定の領域に描画を限定するマスクとして使用される、カレントクリッピングパスのデータはステートに含まれます

/* グラフィックスステートの変更 */
// コンテキストオブジェクトがグラフィックスステートのスタックを含んでいる
let context = UIGraphicsGetCurrentContext()!
// ライン色のステートを変更
context.setStrokeColor(UIColor.black.cgColor)
// ストローク幅のステートを2ptに変更
context.setLineWidth(2.0)
グラフィックスステートの保存・復元

カレントのグラフィックスステートを saveGState()restoreGState() メソッドの実行でコンテキストが持つスタックへ保存・復元(プッシュ・ポップ)ができます。

保存といっても永続化ではなく、グラフィックスステートおよびそのスタックはコンテキストに含まれていますので、コンテキストの寿命と同じ(draw()が呼ばれている間のみ)だと考えられます。

実際の使い方としては、ある一連の描画処理を挟み込むようにして、保存・復元をセットとして使われています。そうすることで、他の描画操作に影響をあたえず、ある一連の描画向けだけに一時的にステートの変更を行うことが出来るからです。

例えば『目を描く』といった一連の処理をメソッドに括り出した(メソッドじゃなくてもいいですが)として、メソッドの初めと終わりにコールしてあげると、このメソッドをコールした外側の処理に副作用を与えない形でグラフィックスステートを『目を描く』用に変更できます。

saveGState() ・・・カレントのグラフィックスステートのコピーをスタックにプッシュします。
restoreGState()・・・スタックの最上位からポップし、カレントのグラフィックスステートに適用します。

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    /* 🌝  */
    // 輪郭を描く
    …
    // 目を描く
    drawEye(inContext: context) 
    // まぶたや頬の膨らみを描く…(drawEyeはGステートを最後に戻したので、この処理を行うGステートは、輪郭を描いた後と同じものになる)
    …
    // 口を描く
    …
}

/// 目のペイントを出力
private func drawEye(inContext: CGContext) {
     inContext.saveGState() // カレントのGステートの**コピー**が、スタックにプッシュされる⤵️
     // 目を描くためにGステートである色、線の幅などを様々に変更したとする
     inContext.setFillColor(UIColor.white.cgColor)
     …
     inContext.fillPath()
     inContext.setStrokeColor(UIColor.black.cgColor)
     …
     inContext.strokePath() // 描画を出力し終えたらGステートを次のリストアで最初に戻す
     inContext.restoreGState() // スタックからポップ⤴️され、カレントのGステートに適用される(このメソッドの最初に保存した状態に戻る)
}

ページのオブジェクト

グラフィックスコンテキストは、ページのオブジェクトとして、パス、テキスト、ビットマップイメージ、イメージマスク、PDFファイル、といったオブジェクトを書き込めます。

Quartz には、直接アクセスできるパスやサブパスのオブジェクト表現はありませんが、 グラフィックスコンテキスト内には、2 つのパスオブジェクトがあります。

・カレントパス・・・形状を描くのに使用されるパス。グラフィックスステートに含まれません。グラフィックスコンテキストでペイントをするとクリアされます。

・カレントクリッピングパス・・・ページ上の特定の領域に描画を限定するために使用されるパス。ペイントしたくない領域を除外できる、マスクとして使えるパス。グラフィックスコンテキストでペイントをしてもクリアされません。グラフィックスステートに含まれます。

パス (Paths)

・パスとは、図形です。直線とベジェ曲線のシーケンスから作成され、ベクトルベースです。
・直線、円、円弧、矩形、多角形、曲線など、単純か複雑な形状を成す、点の集まりです。
・理論的には、テキストも一連の複合パスからなります。
・ラスタライズされたビットマップではなく、点で構成されているため、軽量かつ高速で、異なる解像度でも精度や品質を損なうことなく拡大できます。
・パスは、ペイントすることが出来、輪郭の描画するか、内側の塗りつぶしが出来ます。

パスは、開始点と終了点を持ち、それが連結した状態を「パスが閉じている」、連結されていない状態が「パスが開いている」と表現されてます。

開始点とは「最初のセグメントの開始点」のことで、終了点は「最後のセグメントの終端点」のことです。

開始点と終了点は closePath() で接続することが出来ます。パス(およびサブパス)は、開いていても閉じていてもかまいません。

・グラフィックスコンテキストは、つねに単一のパスしか使用できません。
・理論上、パスはサブパス群を含みます。

サブパス(Subpaths)

・パスは、1つまたは複数のサブパスを定義します。

Quartzでは追加する一連のサブパスが、最終的なパスを形成します。 サブパスの追加および合成の結果がパスとなるので、サブパスとはパスを構成する要素と言えます。

各サブパスは1つ以上のセグメントから構成されています。

・複数のセグメントは、直線と曲線、またはその両方を構成します。
・これら、複数のセグメントを組み合わせることによって、サブパスは 円や星型などの簡単な形状や、山脈のシルエットなどの複雑な形状を作ることが出来ます。

セグメント(Segment)

・セグメントとは、直線あるいは曲線の線分です。
・セグメントをサブパスに追加するとき、セグメントの終端を指定します。
・セグメントの開始点は、同じサブパスの直前のセグメントの終端点です。

パスを閉じると、Quartz は 最後のセグメントの終端点 を 最初のセグメントの開始点 に接続します。

パスの作成に使用できる3つのオブジェクト

(1) CGContext(Core Graphics)
(2) CGPath / CGMutablePath(Core Graphics)
(3) UIBezierPath(UIKit)

(1)には、長方形や楕円などの単純なパスを作成する関数があります。

(2)、(3)では、より複雑なパスの作成をする場合に用いる、とドキュメント(Drawing and Printing Guide for iOS)にあります。(1)のグラフィックスコンテキストに追加したパスは、通常strokefillといったペイントを実行するとクリアされるので、別途パスを保持しておいて再利用する場合に(2)および(3)は便利です。

(2) はパスの作成に特化したオブジェクトであり、描画を行う際には CGContext が必要になります。

iOS開発において描画を行う場合は、(3) の UIBezierPath の利用が推奨されているようです。(当記事では、わかりやすさのため基本的な CGContext を用いてサンプルコードを起こしています。)

これは、描画の際に、基本的には UIGetGraphicsContext()などをコールして得る CGContext の参照を必要としませんが、UIKit 側の Core Graphics ラッパーのため、内部では context にアクセスしています。 (描画設定が同じならば)両方ともパフォーマンスはほとんど変わらないようです。- StackOverflow(記事は古いのですが)

他、UIKitには UIRectFrame(_:)UIRectFill(_:) といったグローバルな関数も 用意されています。使用するローカルのオブジェクトが持つメソッドで代替できることが多いと思うので、その場合あえて使う必要は無いように見えます。

サンプルコード by XCode 7.2 日本語化計画

ペイント

・『パスの作成』と『パスのペイント』は別々のタスクです。まず、パスを作成します。
・ペイント方法は、パスを「ストロークする」「塗りつぶす」または「その両方」 かが選択できます。
ストロークとは・・・カレントパスに沿って線をペイントする処理のこと
・塗りつぶしとは・・・パスで囲まれた領域をペイントする処理のこと

カレントパスを塗りつぶすと、Quartz は各サブパスを個別に塗りつぶします。明示的に閉じられていないサブパスはいずれも、塗りつぶしルーチンにより暗黙的に閉じられます

塗りつぶし(Fill) のアルゴリズム

塗りつぶしのメソッドで選択できる塗りつぶしの方式は2つあります。後述の ノンゼロワインディングルール、奇偶ルールです。

一つの円や長方形といった単純なパスの場合は、どちらのアルゴリズムでも結果に変わりはありません。

同心円などのように、複数のサブパスが含まれていたり、パスの閉じた領域が重なり合ったセグメントで構成されている場合、塗りつぶす領域を決定する方法が必要です。

その際に使用できる2つのルールは、enum CGPathFillRule に定義されています。

・カレントのパスを塗りつぶすとき、Quartz は、パスに含まれる各サブパスが閉じているかのように動作します。閉じたサブパスを使用して、塗りつぶすピクセルを計算します。

(1) case winding ノンゼロワインディングルール(デフォルト)

特定の点を塗りつぶすべきかどうかを判断するには、その点を起点に、
任意の方向に向かって、描画の領域以上の線を引きます。

カウントを0から始めて、パスセグメントが
線を左から右に横切るたびに1を加算、
線を右から左に横切るたびに1を減算
します。

線の終端で横切ったパスセグメントの数を数えます。
カウントの結果が1以上となった場合、その点はパスの内側としてペイントされます。
パスセグメントの描画方向が、結果に影響します。

(2) case evenOdd 奇偶ルール

特定の点を塗りつぶすかどうかを判断するには、その点を起点に、
任意の方向に向かって、描画の領域以上の線を引きます。

線の終端で横切ったパスセグメントの数を数えます。
奇数となった場合、その点はパスの内側としてペイントされます。
パスセグメントの描画方向は、結果に影響しません。

同心円を内側と外側の2つ描く例

f:id:snoozelag:20211022105359p:plain
同心円のパスの向き/塗りつぶしルールによる結果の違い

■ ノンゼロワインディングルール
円が同じ方向に描かれている場合は、両方の円が塗りつぶされます。
円が反対方向に描かれている場合は、内側の円は塗りつぶされません。

■ 奇偶ルール
どの方向に円を描いても、図のような塗りつぶしが行われます。

コードでの再現をサンプルコード集 snoozelag/QuartzZuroku に含めています。

ビュー・描画の更新

ユーザーのアクション等によってQuartzの描画を更新するには、UIViewの描画サイクルを知る必要があります。

システム (iOS)は以下の場合 draw(_:) を呼び出し、ビューのコンテンツの描画を更新します。

(1)ビューが最初に表示されるとき
(2)ビューの可視部分が変化するイベントが発生したとき

(2)について、例えば次のような場合です。

・自分のビューを部分的に隠していた他のビューを、移動または削除した時。また他のビューで隠れた時。
isHidden プロパティを false にし、ビューを非表示から表示にした時。またその逆。
・ビューをスクロールし、画面外に出た時や、再び画面内に戻した時

##### draw(_ rect: CGRect) について

func draw(_ rect: CGrect)UIView - The View Drawing Cycle 項目 のドキュメントに詳しく記載があります。

・このメソッドのデフォルトの実装では何もしません。UIViewの直接のサブクラスならば、このメソッドの実装で super を呼び出す必要はありません。
・Core Graphics などの技術を使用してビューのコンテンツを描画したい場合、このメソッドをオーバーライドして、その描画コードを実装します。(必然的にUIViewを継承したサブクラスになります)
・ビューが「背景色を表示するだけ」の場合や、「layer にコンテンツを直接セット」する場合、このメソッドを実装する必要はありません。
・パラメータの rect: CGRectについて・・・更新が必要なビューの範囲がiOSから渡されます。
ビューを初めて描画するときは、通常、ビューの表示範囲全体です。
2回目以降の描画では、再描画が必要なビューの範囲のみが指定される場合があります。

・描画は、rectパラメータで指定した矩形内に限定する必要があります。
・ビューの isOpaque プロパティが true に設定されている場合、draw(_ rect: CGRect)では、すべて不透明なコンテンツでrectの範囲を満たす必要があります。

isOpaque のドキュメントでは「コンテンツが領域を満たしていない、または透明なコンテンツが含まれている場合、結果は予測できません」とのことで、この箇所について分かりづらい部分があるので詳細と、私の環境で確認したところを付け加えておきます。

環境: Xcode13.0、iOS15、Swift 5

□ まず、UIView#backgroundColor のデフォルト値は nil で、配色としては「透明」になります。
UIView#isOpaque プロパティのデフォルトは true です。
draw(_ rect: CGRect) を実装すると、ビューの描画内容はこのメソッドが担うことになり、デフォルトの「透明」ではなくなります。 参考: ios - UIView Background Color Always Black - Stack Overflow
□ ビューの表示範囲(rect)を描画コンテンツが満たさなかった場合、描画のない領域の配色は「黒色」で出力されました。
□ ビューの表示範囲(rect)を描画コンテンツが満たさないことが分かっている場合、isOpaque プロパティは false にセットするように推奨されています。 そうした場合、描画のない領域の配色は「白色」で出力されるようになりました。

・このメソッドが呼ばれる時点で、UIKit は描画用のグラフィックコンテキストを作成して設定しています。
・UIGraphicsGetCurrentContext() を使用してグラフィックコンテキストへの参照を取得できますが、draw(_ rect: CGRect) メソッドの呼び出しの間にグラフィックコンテキストが変更される可能性があるため、強参照(メンバ変数などへの保持)しないでください。

プログラマによる明示的な再描画

・再描画をする必要がある時、自分でシステムに通知する必要があります。
・ビューを再描画したい時は draw(_ rect: CGRect) は自分で直接コールせずに、 setNeedsDisplay() または setNeedsDisplay(_ rect: CGRect) メソッドをコールしシステムに通知します。
・ビューを更新する必要があることをシステムに知らせます。システムによるビューの更新は次の描画サイクルまで待つことになるため、複数のビューで呼び出し同時に更新することもできます。
・静的な描画コンテンツの場合には、スクロールや他のビューの存在によって生じるビューの可視性の変化(重なったり等)のみの対応で、これはiOSによってdraw(_ rect: CGRect)がコールされるため、setNeedsDisplay()の呼び出しなどは不要です。

ドキュメント

使用例:描画の内容を変更するプロパティのプロパティ監視で利用する

動的なコンテンツのあるビューで、描画に関するパラメータが変更される時などに、 再描画をセットすると有効です。

class SomeView: UIView {

    /// 線の幅
    var lineWidth: CGFloat = 1.0 {
        didSet {
            if oldValue != lineWidth {
                setNeedDisplay()
            }
        }
    }

    override func draw(_ rect: CGRect) {
        …
        context.setLineWidth(self.lineWidth)
        …
    }
}

座標系

Core Graphics のもともとのQuartzの座標系は左下を原点とする LLO (Lower Left Origin) です。

f:id:snoozelag:20211023113045g:plain
オリジナルのQuartz座標系

各システムが採用する座標系

iOS (UIKit):画面の左上を原点とする ULO (upper-left-origin coordinate system)
macOS (AppKit):画面の左下を原点とする LLO (lower-left-origin coordinate system)です。

それぞれのOS間でドローイングのコードを共用する場合、座標系を変換することで 書き換えのコストを省くことも出来ます。

変更された座標系

UIKitを介して呼び出されたグラフィクスコンテキストや、CoreAnimation フレームワークのデフォルト座標は ULOに調整されています。 UIKitが変更された座標系でQuartzのグラフィクスコンテキストを返す理由は、UIKitが異なるデフォルトの座標規則を使用するためです。

・デフォルト座標系とは・・・画面、ビットマップ、PDFなどの各環境(出力先デバイス)に応じて、グラフィックスコンテキストが確立する初期描画座標系のこと。
・UIViewのdraw(_:)が最初に呼び出されたとき、CTM(後述)はULO座標系に構成されます。
・以下は、Quartzで使用されているものとは異なるデフォルト座標系 (ULO) を使用してグラフィックスコンテキストが設定される一般的な例です。
 ∟iOS: UIView や、ほかUIKitを介して返されるグラフィックスコンテキスト全般
 ∟Mac OS X:NSViewのサブクラスでisFlippedをオーバーライドしtrueを返す場合

Quartzと比較して、このような座標系は「変更された座標系」であり、一部のQuartz描画操作を実行するときに補正する必要があります*。→

Quartz 2D が定義する2つの座標空間

・「ユーザー空間」・・・ページを表します。座標は浮動小数点数で、デバイス空間のピクセルの解像度とは無関係です。CTMを操作することにより、デフォルトのユーザー空間を変更できます。通常は、Quartz 2Dで描画する場合、ユーザー空間でのみ作業します。描画コマンドは、常にユーザー空間の座標を使って位置を指定します。

・「デバイス空間」・・・デバイスのネイティブな座標空間のこと。デバイス座標空間の単位はピクセルで指定され、この空間の解像度はデバイスに依存します。描画を行う際に、デバイス座標空間を気にする必要はほとんどありません。Quartzは、カレントの変換行列(CTM)を使用して、ページを表示する際に、ユーザー空間座標から、このデバイス空間座標に変換・マッピングします。

Quartzが行う、「ユーザー空間」から「デバイス空間」への変換に用いる、アフィン変換は var userSpaceToDeviceSpaceTransform: CGAffineTransform で取得できます。

この仕組みにより、出力先のデバイス環境が異なっていても、グラフィックの場所とサイズをデバイスに依存しない方法で、定義することが出来ます。

カレントの変換行列 (CTM)

・CTMとは・・・current transformation matrix。カレントの変換行列。ユーザー空間座標系からデバイス座標系へ、マッピングを指示する変換行列です。
・デフォルトでは、UIKitはポイントをピクセルにマップする単純なCTMを作成します。

たとえば、45度回転したボックスを描画するには、ボックスを描画する前にページの座標系(CTM)を回転させます。

CTMはグラフィックスステートに含まれます。一連のドローイングの中で、他の描画に影響を与えずある特定の描画のためにCTM変更する場合、描画の前にグラフィックスステートを保存し、描画の後で復元することで出来ます。

CTMを変更する2つの方法

Core Graphicsフレームワークでは、CTMを変更する方法が2つあります。

■ CTM操作関数の使用する方法

CTMは、グラフィックスコンテキストを使用して、ページの回転、拡大縮小、移動を実行することにより直接変更することができます。

translateBy(x:y:) コンテキストのユーザー座標系の原点を変更
scaleBy(x:y:) コンテキストのユーザー座標系のスケールを変更
rotate(by:) コンテクストのユーザー座標系を回転

■ CGAffineTransform 構造体を作成してから連結 (concatenating) https://developer.apple.com/documentation/coregraphics/cgaffinetransform:title= CGAffineTransform 構造体]を作成し、必要な変換を適用してから、その変換をCTMに連結することもできます。こうすることで、変換をグループ化し、CTMに一度に適用できます。

・CTMは、グラフィックスコンテキストの var ctm: CGAffineTransform { get } で取得できます。

座標変換を使用して描画パフォーマンスを向上させるテクニック

・デフォルトでは、UIKitはポイントをピクセルにマップする単純なCTMを作成します。そのマトリックスを変更せずにすべての描画を行うことができますが、座標変換を使用すると便利な場合があります。
・パスの作成は比較的コストのかかる操作であるため、CTMの変更によりパスを再利用すると、描画中に必要な計算量が削減される可能性があります。

例えば、x: 10, y: 10の位置とx: 20, y: 20などの位置に、複数の正方形の描画を行いたいとします。 それぞれの位置に同じ形のパスを都度作成するよりも、まず原点がx: 0, y: 0の正方形の CGPath を作成しておき、これを再利用します。正方形のパスが目的の原点に描画されるように、CTMを変更することをおすすめします。

デフォルト座標系の反転

・ドローイングにUIKitメソッドや関数のみしか使わないのであれば、CTMを反転する必要はありません。

iOSのみでコーディングしても、Core Graphics を直接扱うコードがある時に座標系の変換を意識する必要があります。

・Core GraphicsやImage I/Oの関数呼び出しと、UIKitの呼び出しが混在している場合、CTMを反転する必要があります。具体的には、Core Graphics関数を直接呼び出して、画像またはPDFドキュメントを描画すると、UIViewのコンテキストでは上下逆にレンダリングされます。画像やPDFドキュメントを正しく表示するには、CTMを反転しデフォルト座標系をULOからLLO座標系に揃えます。

このことについて、こちらに実装での具体的な事例が掲載されています → stackoverflow - CGContextDrawImage draws image upside down when passed UIImage.CGImage

この問題の解決には、つまり「(代替できれば)使用するメソッドをUIKitに統一する」か、この段以降に紹介している「CTMを反転する方法」になります。

CTMを変換するコードの例

context.saveGState()
context.translateBy(x: 0, y: imageHeight) // 原点を作図領域の左上に移動
context.scaledBy(x: 1, y: -1) // y座標を負にする(-1)スケーリング変換を適用。スケーリングは座標系の原点を変更しません。
context.draw(image, in: CGRect(x: 0, y: 0, width: imageWidth, height: imageHeight)) 
context.restoreGState()

・また、CGImage を引数にして `UIImage` をイニシャライズするとき UIKit はCGImageにフリップ変換を行っています。

CGImageオブジェクトの座標系はLLOなので、この時もULO変換を行なっているとのこと。

UIViewdraw(_:)の中で受け取る CGContext は、CGが接頭辞にあるんですが、この場合はUIKitを介しているので座標系がULOに変換されている、という風に読み取れます(実際の挙動もそうです)。その他、UIKitを介して取得した CG系オブジェクトについても同様なのではないかと思います。 その場合と、Core Graphics を直接使用する場合では、デフォルト座標系が異なるということを心に留めておく必要がありそうです。

アークとローテーション(円弧と回転)の注意事項

Core Graphics (Quartz) は macOS および iOS で使用されており、iOS(UIKit)上のグラフィックスコンテキストの場合には、もともとのQuartzの座標系 (LLO) から、UIKitに合わせて原点が上に変更され y軸の正の方向が反転された座標系 (ULO) になっています。円弧の描画を反転すると、以下の図の通りに回転の向きが変わります。

f:id:snoozelag:20211023234244p:plain
Core Graphics とUIKitでのアークレンダリング

Drawing and Printing Guide for iOS では、

・円弧は、この「ルール」に従ったとしても補正が必要なパスです。(Note: Arcs are an aspect of paths that require additional work even if this “rule” is followed.)

・ULO 座標系を参照する Core Graphics 呼び出しを使用して、オブジェクトを回転させると、UIKit でレンダリングされるときのオブジェクトの方向が逆になります。( ​If you rotate an object using Core Graphics calls that make reference to a ULO coordinate system, the direction of the object when rendered in UIKit is reversed.)

などの注意書きがあります。

また、Quartz 2D Programming Guide では、

・y 座標を負にするスケーリング変換を使用すると、Quartz 描画のいくつかの規則が変更されます。座標系が変更されると、鏡に映ったイメージのように結果も変更されます。
・パス描画ルーチンは、デフォルトの座標系で円弧を時計回りに描画するかを指定するパラメータを受け取ります。同じパラメータをQuartzに渡すと、デフォルト座標系では時計回りの円弧が描かれ、変換によってy座標が反転されると反時計回りの円弧が描かれます。
・ULOベースの座標系を考慮して、Core Graphicsで描かれた円弧の方向を変更するには、これらの関数のstartAngleendAngleパラメータで制御します。

といった説明がなされています。

これらの原因で特定の描画環境における CGContext#addArc() もしくは CGMutablePath#addArc()UIBezierPath#addArc() での clockwise: Bool 引数の挙動は異なります。

UIBezierPath#addArc()・・・デフォルトの座標系で描画された場合、開始角度と終了角度は、図に示す単位円がベースになります。例えば、startAngle(開始角度)0ラジアンendAngle(終了角度)πラジアンとしclockwise(時計回り) パラメータを true に設定すると、円の下半分が描画されます。一方、同じ開始角度と終了角度を指定し、clockwise パラメータを false に設定した場合は、円の上半分が描画されます。

f:id:snoozelag:20211023235143j:plain
UIBezierPathの円弧がベースにする単位円

CGContext#addArc()・・・グラフィックスコンテキストのCTMが反転された座標系(iOS の UIView draw(_:)メソッドのデフォルト)では、最終的なパスの実際の方向は、時計回りの円弧を指定すると、変換が適用され反時計回りの円弧になります

■実際のコードの比較

f:id:snoozelag:20211025130837p:plain

/* 画面右下に円弧のパスを追加するコードでの比較 */
class DrawView: UIView {
    /// どちらも同じ描画結果になりますが、`clockwise`に対するBoolの値が逆なことに注目
    override func draw(_ rect: CGRect) {
        ・・・
        // UIBezierPath
        path.addArc(withCenter: center, radius: 30, startAngle: 0, endAngle: .pi/2, clockwise: true)
        // CGContext
        context.addArc(center: center, radius: 30, startAngle: 0, endAngle: .pi / 2, clockwise: false)
    }
}

f:id:snoozelag:20211025125543j:plain
変更された座標系の CGContext (in UIView) で想定される単位円

UIBezierPath 単位円の図を反転すると、コードとの挙動が一致し、UIView での CGContext の Arcパスを追加するときの単位円は、上の図のように想定できることがわかります。

ios - Why CGPath and UIBezierPath define "clockwise" differently in SpriteKit? - Stack Overflow でも議題にあがっていますが、つまり、UIBezierPathクラスを使って作成した場合と逆の方向を向いているので注意が必要です。

ストローク(Stroke) の注意事項

ストロークは、パスに対して、線をペイントするコマンドです。 lineWidth(線の幅)strokeColor(線の色)が主なパラメータです。

UIViewのboundsの端に近い部分に、パスが引かれている場合、 ストロークを実行すると、そのlineWidthのパラメータ設定によっては、 boundsの領域からはみ出してしまい、そのペイント部分が表示されないので 注意事項が必要です。

例えば、次のようなサンプルです。

class DrawView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        isOpaque = false
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        isOpaque = false
    }

    override func draw(_ rect: CGRect) {
        let context = UIGraphicsGetCurrentContext()!
        context.setLineWidth(10)
        context.setStrokeColor(UIColor.red.cgColor)
        context.addEllipse(in: bounds)
        context.strokePath()
    }
}

f:id:snoozelag:20211024213806p:plain

Playgroundによる表示ですが、赤い線(10ptの太さ)がはみ出して表示されていないことがわかります。

addEllipse(in:)は楕円のパスを追加するメソッドですが、その引数にbounds(ビューの領域そのもの)を指定しました。つまり、ビューの大きさと同じ長方形に納まる楕円が描かれることになります。

なぜ楕円の線の表示されない部分があるかというと、原因は strokePath() ペイントはパスを中心から線の幅がとられて描画されるためです。上下左右の部分で最大、線の幅の半分(5pt)ほどはみ出しています。これを修正するには、線の幅の半分ほど小さな長方形をaddEllipse(in:)に指定します。

こんな時に便利なメソッドが CGRect#insetBy(dx:dy:)です。このメソッドを利用すると、実行したrectインスタンスと同じ中心点を持つ、元の長方形よりも小さい長方形を返すことが出来ます。これを利用して先程の描画を修正します。

// addEllipseに渡すrectを線の幅半分ほど
override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let lineWidth: CGFloat = 10
    let halfLineWidth = lineWidth / 2
    context.setLineWidth(lineWidth)
    context.setStrokeColor(UIColor.red.cgColor)
    context.addEllipse(in: bounds.insetBy(dx: halfLineWidth, dy: halfLineWidth))
    context.strokePath()
}

f:id:snoozelag:20211024212322p:plain
strokeを実行するパスはlineWidthの半分、内側にする

ストロークを実行するパスは、ビューの端に追加すると、パスの線上を中心にしてペイントされるため、線の半分の描画がはみ出してしまいますので注意が必要です。

描画サンプル

サンプルで用いるUIViewサブクラスの雛形になります。

@IBDesignable // (1) Storyboard、Xib でのプレビューを有効化
class SomeView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        isOpaque = false // (2) rect 引数の領域全体を描画で埋めない場合は false を設定する
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        isOpaque = false
    }

    override func draw(_ rect: CGRect) {
         /* (3) 描画を行うコードをここに描く */
    }
}

(1) @IBDesignable を利用すると Storyboard、Xib への追加時に描画内容がレンダリングされプレビューできます。(オプション)
(2) 背景を満たす描画コンテンツ配置しない場合 isOpaque プロパティを false にします。
(3) func draw(_ rect: CGRect) 内でグラフィックスコンテキストの操作および描画のコーディングをします。

直線

一本の直線

f:id:snoozelag:20211024213619p:plain

@IBDesignable
class DrawView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        isOpaque = false
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        isOpaque = false
    }

    override func draw(_ rect: CGRect) {
        // 直線一本
        let context = UIGraphicsGetCurrentContext()!
        context.move(to: CGPoint(x: 0, y: rect.midY))
        context.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
        context.setStrokeColor(UIColor.black.cgColor) // 色を設定
        context.setLineWidth(2.0) // ストローク幅を2ptに設定
        context.strokePath()
    }
}

move(to:) 指定したポイントを新しいサブパスの開始ポイントおよび現在ポイントとして設定します。 セグメントを描画せずに現在の点を移動できます。その他のすべてのメソッドは、パスに直線または曲線のセグメントを追加します。

新しいセグメントを追加するメソッドは、常にカレントのポイントから開始し、指定した新しいポイントで終了します。 セグメントを追加したとき、新しいセグメントの終点は自動的にカレントのポイントになります。

addLine(to:) 現在のポイントから指定されたポイントまでの線分を追加します。

setLineWidth(_:)は0より大きい必要があり、デフォルト値は1です。

setStrokeColor(_:)は、実行しなかった場合、黒で描画される事からRGBのデフォルト値が0に設定されていると思われます。 これらsetStrokeColorsetLineWidthcontextに対し実行しなかったとしても、strokePath()の実行では1ptの黒い線分で描画されます。

strokePath()を実行すると パスがペイントされますが、副作用としてペイントした現在のパス(move(to:)およびaddLine(to:)で追加したパス)はクリアされます。つまり、さらに図形の描画を重ねる場合には、contextに新たなパスを追加する必要があります。

ひとつながりの線(ジグザグ)

f:id:snoozelag:20211024221240p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    context.setStrokeColor(UIColor.black.cgColor)
    context.setLineWidth(2)

    let pathPoints = [
        CGPoint(x: bounds.maxX * (0/3), y: bounds.maxY),
        CGPoint(x: bounds.maxX * (1/3), y: 0),
        CGPoint(x: bounds.maxX * (1/3), y: bounds.maxY),
        CGPoint(x: bounds.maxX * (2/3), y: 0),
        CGPoint(x: bounds.maxX * (2/3), y: bounds.maxY),
        CGPoint(x: bounds.maxX * (3/3), y: 0),
    ]
    context.addLines(between: pathPoints)
    context.strokePath()
}

addLines(between:) ポイントの配列を引数として、描画する線分の始点と終点を指定できます。配列の最初の点は、開始点を指定します。move(to:)で開始点を指定し、続く点を全てaddLine(to:)で指定するのと等価となるコンビニエンスメソッドです。

以下はcontext.addLines(between: pathPoints) と同じ結果となるコードです。

context.move(to: pathPoints[0])
for i in 1..<pathPoints.count {
    context.addLine(to: pathPoints[i])
}
複数の直線

f:id:snoozelag:20211024221554p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    context.setStrokeColor(UIColor.black.cgColor)
    context.setLineWidth(2)

    let pathPoints = [
        CGPoint(x: bounds.maxX * (0/3), y: 100),
        CGPoint(x: bounds.maxX * (1/3), y: 10),
        CGPoint(x: bounds.maxX * (1/3), y: 100),
        CGPoint(x: bounds.maxX * (2/3), y: 10),
        CGPoint(x: bounds.maxX * (2/3), y: 100),
        CGPoint(x: bounds.maxX * (3/3), y: 10),
    ]
    // 要素の1番目と2番目がペア、3番目と4番目がペア…という風に2つの点のペアの直線パスが作成される
    context.strokeLineSegments(between: pathPoints)
}

strokeLineSegments(between:) ペアとして編成されたポイント配列を引数に取り、複数の線分のパスを作成し描画します。同時にペイントを行うため、strokePath()と同じくパスがクリアされます。

同じ結果となるコードは以下の通りです。

context.move(to: CGPoint(x: bounds.maxX * (0/3), y: 100))
context.addLine(to: CGPoint(x: bounds.maxX * (1/3), y: 10))
context.strokePath()

context.move(to: CGPoint(x: bounds.maxX * (1/3), y: 100))
context.addLine(to: CGPoint(x: bounds.maxX * (2/3), y: 10))
context.strokePath()

context.move(to: CGPoint(x: bounds.maxX * (2/3), y: 100))
context.addLine(to: CGPoint(x: bounds.maxX * (3/3), y: 10))
context.strokePath()

長方形

f:id:snoozelag:20211024221906p:plain

赤い囲みの長方形、青い塗りつぶしの長方形、2つを描くサンプルです。 長方形のパスを追加専用のメソッド addRect(_ : CGRect) が用意されています。

class RectangleView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        isOpaque = false
        backgroundColor = .lightGray
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        isOpaque = false
        backgroundColor = .lightGray
    }

    override func draw(_ rect: CGRect) {
        let context =  UIGraphicsGetCurrentContext()!
        let lineWidth: CGFloat = 10
        let halfLineWidth = lineWidth / 2
        // 赤い線で囲まれた長方形
        context.setStrokeColor(UIColor.red.cgColor)
        context.setLineWidth(lineWidth)
        context.addRect(CGRect(x: 0, y: 0, width: bounds.midX, height: bounds.midY).insetBy(dx: halfLineWidth, dy: halfLineWidth))
        context.strokePath()

        // 青で塗りつぶしされた長方形
        context.setFillColor(UIColor.blue.cgColor)
        context.addRect(CGRect(x: bounds.midX, y: bounds.midY, width: bounds.midX, height: bounds.midY))
        context.fillPath()
    }
}

addRect(_ rect: CGRect) は パスに長方形を追加します。左下隅から反時計回りに線分を追加し長方形を作成し、サブパスを閉じます。

fillPath() 現在のパス内の領域をペイントします。閉じられていないサブパスが含まれている場合、指定されたルールを適用して塗りつぶす領域を決定します。

setFillColor(_ color: CGColor) 現在の塗りつぶしの色を設定

下記のコンビニエンスメソッドを用いても同じ結果が出力できます。

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let lineWidth: CGFloat = 10
    let halfLineWidth = lineWidth / 2
    // 赤い線で囲まれた長方形
    context.setStrokeColor(UIColor.red.cgColor)
    context.stroke(CGRect(x: 0, y: 0, width: bounds.midX, height: bounds.midY).insetBy(dx: halfLineWidth, dy: halfLineWidth), width: lineWidth)

    // 青で塗りつぶしされた長方形
    context.setFillColor(UIColor.blue.cgColor)
    context.fill(CGRect(x: bounds.midX, y: bounds.midY, width: bounds.midX, height: bounds.midY))
}

strokePath()fillPath()stroke(_:)fill(_:) いずれもペイントを実行しますのでcontextのパスはクリアされます。

context.addRect(CGRect(x: 0, y: 0, width: bounds.midX, height: bounds.midY).insetBy(dx: halfLineWidth, dy: halfLineWidth))

上記の部分では、ビューの端にあるパスにストロークの描画をすると、線の半分の描画がはみ出してしまう(パスの線上を中心にしてペイントされるため)のを修正するためinsetBy(dx:dy:)を利用し、中心が同じでlineWidth(線の幅)の半分ほど小さい矩形を算出しています。

insetBy(dx:dy:)・・・同じ中心点を持つ、元の長方形よりも小さいまたは大きい長方形を返す

insetBy(dx:dy:)を使用しなかった場合の結果

f:id:snoozelag:20211024230746p:plain

赤い線のペイントがビューからはみ出しています。

f:id:snoozelag:20211024231236p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    context.setStrokeColor(UIColor.black.cgColor)
    context.setLineWidth(2)
    // 円(ライン)
    context.addEllipse(in: CGRect(x: 10, y: 10, width: 60, height: 60))
    context.strokePath()
}

addEllipse(in rect: CGRect) ・・・指定した rect (長方形) に収まる楕円のパスを追加

または、パスの追加とペイントを同時に行うコンビニエンスメソッドも利用できます。

// 円(ライン)
context.strokeEllipse(in: CGRect(x: 30, y: 30, width: 60, height: 60))
// 円(塗りつぶし)
context.fillEllipse(in: CGRect(x: 30, y: 30, width: 60, height: 60))

strokeEllipse(in rect: CGRect) ・・・指定した rect (長方形) に収まる楕円のパスを追加しストローク
strokeEllipse(in rect: CGRect) ・・・指定した rect (長方形) に収まる楕円のパスを追加し、領域を塗りつぶし

円弧

f:id:snoozelag:20211024231442p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let center = CGPoint(x: 45, y: 45)
    // 右下の円弧
    context.addArc(center: center, radius: 30, startAngle: 0, endAngle: .pi/2, clockwise: false)
    // context.strokePath()
    // 左上の円弧
    context.addArc(center: center, radius: 30, startAngle: 3 * .pi / 2, endAngle: .pi, clockwise: true)
    context.strokePath()
}

円弧と直線のサンプル。最初のパスの追加の後、strokePath()を実行しない場合、パスはクリアされないのでひとつながりになり直線部分がでます。

f:id:snoozelag:20211024233012p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    context.addArc(center: CGPoint(x: 45, y: 45), radius: 30, startAngle: 0, endAngle: CGFloat.pi/2, clockwise: false)
    // context.strokePath()

    context.addArc(center: CGPoint(x: 45, y: 45), radius: 30, startAngle: 3*CGFloat.pi/2, endAngle: CGFloat.pi, clockwise: true)
    context.strokePath()
}

以下は、塗りつぶしを行なった場合の挙動はどうなるのか、上記の2パターンの例をstrokePath()からfillPathh()に変更したものです。

f:id:snoozelag:20211024233053p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let center = CGPoint(x: 45, y: 45)
    // 右下の円弧
    context.addArc(center: center, radius: 30, startAngle: 0, endAngle: .pi/2, clockwise: false)
    context.fillPath()
    // 左上の円弧
    context.addArc(center: center, radius: 30, startAngle: 3 * .pi / 2, endAngle: .pi, clockwise: true)
    context.fillPath()
}

f:id:snoozelag:20211024233104p:plain

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let center = CGPoint(x: 45, y: 45)
    // 右下の円弧
    context.addArc(center: center, radius: 30, startAngle: 0, endAngle: .pi/2, clockwise: false)
    // context.fillPath()
    // 左上の円弧
    context.addArc(center: center, radius: 30, startAngle: 3 * .pi / 2, endAngle: .pi, clockwise: true)
    context.fillPath()
}
曲線

■ 2次ベジェ曲線

f:id:snoozelag:20211025023904p:plain

override func draw(_ rect: CGRect) {
    let start = CGPoint(x: 0, y: 100)
    let end = CGPoint(x: 100, y: 90)
    let cp1 = CGPoint(x: 50, y: 10)

    let context = UIGraphicsGetCurrentContext()!
    context.setLineWidth(3)
    context.setStrokeColor(UIColor.black.cgColor)
    context.move(to: start)
    context.addQuadCurve(to: end, control: cp1)
    context.strokePath()
}

addQuadCurve(to:controlPoint:) ・・・パスに2次ベジェ曲線を追加します。開始点(カレントポイント)、終端点、制御点で関係で曲線を定義します。

開始点から終端点へ向かう直線上に制御点が近づくほど、カーブは緩やかになります。

■ 3次ベジェ曲線

f:id:snoozelag:20211025024326p:plain

override func draw(_ rect: CGRect) {
    let start = CGPoint(x: 0, y: 60)
    let end = CGPoint(x: 100, y: 50)
    let cp1 = CGPoint(x: 40, y: 10)
    let cp2 = CGPoint(x: 60, y: 90)

    let context = UIGraphicsGetCurrentContext()!
    context.setLineWidth(3)
    context.setStrokeColor(UIColor.black.cgColor)
    context.move(to: start)
    context.addCurve(to: end, control1: cp1, control2: cp2)
    context.strokePath()
}

addCurve(to:controlPoint1:controlPoint2:) ・・・パスに3次ベジェ曲線を追加。2つの制御点は、セグメントの曲率(きょくりつ)を定義します。

サンプルコード集 snoozelag/QuartzZuroku には、それぞれのポイントを動かせるDEMOを収録しています。 どの点を動かせば、どういった風に曲線が出来るのかが確認できます。

f:id:snoozelag:20211024234637p:plain

UIBezierPath

ここまでCGContext を用いた描画について紹介しましたが、UIBezierPath を用いてのドローイングもできます。

これまでのサンプルを 直接 CGContext から UIBezierPathで書き直したものを [snoozelag/QuartzZuroku] に収録しています。

UIBezierPathCGPath のUIKitラッパークラスです。 パスの作成、ペイントの両方の操作を行えます。

利用方法はCGContextを用いたドローイングとそこまで変わりはないです。CGContextと異なる点は、stroke()fill() でペイントを実行後もクリアされることなく UIBezierPathはパスを保持しています。そのパスは、そのまま再利用することが可能です。

別の図形のパスの作成を開始したい場合removeAllPoints()を実行してそれまでのパスを破棄して同じ参照を利用するか、新しく UIBezierPathを作成します。

■CGContext と比較した際のコーディングの特徴

・グラフィクスステートのカラーを変更には UIColorのメソッドを用いる。

// CGContext
context.setStrokeColor(UIColor.red.cgColor)
context.setFillColor(UIColor.red.cgColor)
vs
//  UIBezierPath
UIColor.red.setStroke()
UIColor.red.setFill()

・CGContext のようにstrokefillのペイントをした後にパスがクリアされない。removeAllPoints()の実行で追加したサブパスを破棄できる。

円弧を追加する関数・イニシャライザで、clockwise: Bool の引数の挙動がCGPath / CGContext と逆になります。 (Core Graphics デフォルト座標系の状態が異なるため。このことは当記事の『アークとローテーション(円弧と回転)の注意事項』にまとめています。)

override func draw(_ rect: CGRect) {
    let center = CGPoint(x: 45, y: 45)
    let path = UIBezierPath()
    // 右下の円弧
    path.addArc(withCenter: center, radius: 30, startAngle: 0, endAngle: .pi / 2, clockwise: true)
    path.stroke()
    path.removeAllPoints()
    // 左下の円弧
    path.addArc(withCenter: center, radius: 30, startAngle: 3 * .pi / 2, endAngle: .pi, clockwise: false)
    path.stroke()
}

・strokeLine(between: )などの関数がない。(CGContext を併用するか、自分で実装)

let path = UIBezierPath()
・・・
path.addLines(between: pathPoints)

extension UIBezierPath {

    /// CGContext#addLines(between:)と同じになる実装
    func addLines(between pathPoints: [CGPoint]) {
        guard !pathPoints.isEmpty else { return }
        move(to: pathPoints[0])
        for i in 1..<pathPoints.count {
            addLine(to: pathPoints[i])
        }
    }
}
■ CGPath との比較

・UIBezierPath は NSCoding プロトコルに適合しているため、シリアライズ・デシリアライズに対応していることが利点
・CGPath には lineWidth や ペイントに関するメソッドがない
・UIView においての描画で Arcパスの追加の clockwise の値に対し、挙動がそれぞれ異なる

参考: StackOverflow - What is the difference between CGPath and UIBezierPath?

■ CGContext より UIBezierPath の方が速い?

Why is UIBezierPath faster than Core Graphics path?(UIBezierPathがCGPathよりも速いのはなぜ?) というトピックが上がっていますが、 回答を見ると、検証時のlineWidthの設定がそれぞれ異なっていたことがパフォーマンスに影響しただけで、ほとんど変わらないと結論されているようです。

■ 変換

・CGPath → UIBezierPath init(cgPath: CGPath)

let cgPath = CGMutablePath()
cgPath.addRect(CGRect(x: 0, y: 0, width: 100, height: 100))

let path = UIBezierPath(cgPath: cgPath)
もしくは
let path = UIBezierPath()
path.cgPath = cgPath

・UIBezierPath → CGPath var cgPath: CGPath { get set }

let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100))
let mutableCGPath = path.cgPath.mutableCopy()

SwiftUIでのドローイング

SwiftUIでのドローイングは struct Pathprotocol Shape : Animatable, View が利用できます。

Pathを使用するパターン

Pathを用いて、ペインタモデルで紹介したサンプルと同じ長方形を描画します。

struct SwiftUI_PathSampleView: View {

    var body: some View {
        VStack(alignment: .center) {
            RectanglesView1()
            RectanglesView2()
        }.navigationBarTitle("Drawing in SwiftUI (1)")
    }

    struct RectanglesView1: View {
        var body: some View {
            // 例(1)
            ZStack {
                Color(.lightGray)
                ZStack {
                    // 青い長方形(ライン)を描画
                    Path { path in
                        path.addRect(CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: 100, height: 100)))
                    }
                    .stroke(Color(.blue))
                    // 赤い長方形(塗りつぶし)を描画
                    Path { path in
                        path.addRect(CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(width: 100, height: 100)))
                    }
                    .fill(Color(.red))
                }.frame(width: 110, height: 110)
            }
        }
    }

    struct RectanglesView2: View {
        var body: some View {
            // 例(2)
            ZStack {
                Color(.lightGray)
                ZStack {
                    // 赤い長方形(塗りつぶし)を描画
                    Path { path in
                        path.addRect(CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(width: 100, height: 100)))
                    }
                    .fill(Color(.red))
                    // 青い長方形(ライン)を描画
                    Path { path in
                        path.addRect(CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: 100, height: 100)))
                    }
                    .stroke(Color(.blue))
                }.frame(width: 110, height: 110)
            }
        }
    }
}
■ Shape を使用するパターン

・Pathとの違いは自身の表示領域であるrectが利用できる点です。

struct SwiftUI_ShapeSampleView: View {

    struct RectView: Shape {
        func path(in rect: CGRect) -> Path { // 自身の表示領域であるrectが利用できる
            var path = Path()
            path.addRect(rect)
            return path
        }
    }

    var body: some View {
        VStack(alignment: .center) {
            RectanglesView1()
            RectanglesView2()
        }.navigationBarTitle("Drawing in SwiftUI (2)")
    }

    struct RectanglesView1: View {
        var body: some View {
            // 例(1)
            ZStack {
                Color(.lightGray)
                ZStack {
                    // 青い長方形(ライン)を描画
                    RectView()
                        .stroke(Color(.blue))
                        .frame(width: 100, height: 100)
                        .offset(x: 0, y: 0)
                    // 赤い長方形(塗りつぶし)を描画
                    RectView()
                        .fill(Color(.red))
                        .frame(width: 100, height: 100)
                        .offset(x: 10, y: 10)
                }
            }
        }
    }

    struct RectanglesView2: View {
        var body: some View {
            // 例(2)
            ZStack {
                Color(.lightGray)
                ZStack {
                    // 赤い長方形(塗りつぶし)を描画
                    RectView()
                        .fill(Color(.red))
                        .frame(width: 100, height: 100)
                        .offset(x: 10, y: 10)
                    // 青い長方形(ライン)を描画
                    RectView()
                        .stroke(Color(.blue))
                        .frame(width: 100, height: 100)
                        .offset(x: 0, y: 0)
                }
            }
        }
    }
}

いずれもサンプルコード集 snoozelag/QuartzZuroku に収録しています。

UIImageを作成しUIImageViewで表示する

基本的な、UIViewのサブクラス化して利用する方法以外にも、Core Graphics を使う方法はあります。 UIGraphicsImageRenderer を利用して、UIImageとして描画を出力し、 UIImageView の画像として割り当てます。

例として、ペインタモデルで紹介した長方形を描画し、そのUIImageを作成します。UIImageViewで表示します。

class SomeViewController: UIViewController {

    private let imageView = UIImageView()

    override viewDidLoad() {
        super.viewDidLoad()

        imageView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(imageView)
        imageView.topAnchor.constraint(equalTo: view.topAnchor)isActive = true
        imageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 11).isActive = true
        imageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -11).isActive = true
        imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)isActive = true

        imageView.backgroundColor = .lightGray
        imageView.image = paintersModelSample1()
        imageView.contentMode = .center
    }

    func paintersModelSample1() -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: CGSize(width: 110, height: 110))
        let image = renderer.image { rendererContext in
            let context = rendererContext.cgContext
            // 例(1)
            let size = CGSize(width: 100, height: 100)
            // 青い長方形(ライン)を描画
            context.setStrokeColor(UIColor.blue.cgColor)
            context.addRect(CGRect(origin: CGPoint(x: 0.5, y: 0.5), size: size))
            context.strokePath()
            // 赤い長方形(塗りつぶし)を描画
            context.setFillColor(UIColor.red.cgColor)
            context.addRect(CGRect(origin: CGPoint(x: 10, y: 10), size: size))
            context.fillPath()
        }
        return image
    }
}

func image(actions: (UIGraphicsImageRendererContext) -> Void) -> UIImage を利用し、UIGraphicsImageRendererContextcgContextプロパティ に対してQuartzの描画操作を行い、結果をUIImageとして受け取ることができます。

UIView#layer、CALayerでのDrawing

UIViewがもつlayerプロパティCALayerクラスのオブジェクトで、Core Animation の領域に分類されています。

・画像ベースのコンテンツを管理し、そのコンテンツに対してアニメーションを実行するためのオブジェクトです。

この layer に対し、CoreGraphics を使った図形の描画が追加できます。いくつかのパターンを紹介します。 例として、ペインタモデルで紹介した二つの長方形をレイヤー作成用に書き換え、描いてみます。

■ CALayerの draw(in:) を実装しドローイングするパターン https://developer.apple.com/documentation/quartzcore/calayer/1410757-draw:title=draw(in ctx: CGContext)を実装し、 このサブクラスのインスタンスaddSublayer(_:) で任意のビューに追加します。

class RectanglesLayer: CALayer {

    override func draw(in context: CGContext) {
        let size = CGSize(width: 100, height: 100)
        // 青い長方形(ライン)を描画
        context.setStrokeColor(UIColor.blue.cgColor)
        context.addRect(CGRect(origin: .zero, size: size))
        context.strokePath()
        // 赤い長方形(塗りつぶし)を描画
        context.setFillColor(UIColor.red.cgColor)
        context.addRect(CGRect(origin: CGPoint(x: 10, y: 10), size: size))
        context.fillPath()
    }
}

■ CAShapeLayer を利用し path をセットして表示するパターン

private func createRectanglesView() -> UIView {
    let view = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 110, height: 110)))
    let size = CGSize(width: 100, height: 100)

    // 青い長方形(ライン)を描画
    let path2 = CGMutablePath()
    path2.addRect(CGRect(origin: .zero, size: size))

    let blueLineLayer = CAShapeLayer()
    blueLineLayer.strokeColor = UIColor.blue.cgColor
    blueLineLayer.fillColor = UIColor.clear.cgColor // fillColorにclearを設定しない場合、黒で塗り潰しされます
    blueLineLayer.path = path2
    view.layer.addSublayer(blueLineLayer) // Sublayerに追加する

    // 赤い長方形(塗りつぶし)を描画
    let path = CGMutablePath()
    path.addRect(CGRect(origin: CGPoint(x: 10, y: 10), size: size))

    let redFillLayer = CAShapeLayer()
    redFillLayer.fillColor = UIColor.red.cgColor
    redFillLayer.path = path
    view.layer.addSublayer(redFillLayer) // SubLayerに追加する

    return view
}

CASharpLayerpathプロパティにセットされたパスは、fillColorstrokeColorのパラメータが反映されてペイントされます。

■ UIView#layer.maskにpathをセットするパターン 作成した CGPathmask プロパティにセットすると、そのレイヤーのペイントをクリッピングするマスクとして利用できます。

let layer = CAShapeLayer()
layer.mask = path
view.layer.addSublayer(layer)

当記事のサンプルコード集

snoozelag/QuartzZuroku

参考

Quartz 2D Programming Guide

Drawing and Printing Guide for iOS

Drawing With Quartz 2D

Cocoa Drawing Guide

その他、文中に記載。

間違いがありましたら、ご指摘いただけると大変助かります。 @snoozelag
この記事が開発のお役に立てたなら幸いです。