SNOOZE LOG

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

詳しく知りたい 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
この記事が開発のお役に立てたなら幸いです。