5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】Pathまとめ

Last updated at Posted at 2024-10-14

これは何?

Pathでいざ図形を描画する際に実際どういうAPIが存在しているのか把握してなかったので、Pathのドキュメントを上から下まで読んで自分なりにまとめたものです。

Pathの基本

Pathは親Viewの左上を原点とし、右に行くほどXが+、下に行くほどYが+になる。
詳細は後述するが、以下が基本的な構造で、Pathのクロージャ内でmove(to:)で描画開始位置に移動し、addLine(to:)で線を引いてcolosePath()で閉じるといった感じ。

struct ContentView: View {
    var body: some View {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 10))
            path.addLine(to: CGPoint(x: 150, y: 10))
            path.addLine(to: CGPoint(x: 100, y: 110))
            path.closeSubpath()
        }
        .stroke(.green, lineWidth: 5)
    }
}

スクリーンショット 2024-10-12 16.05.15.png

空のPathを生成してからaddしていくことも可能。

struct ContentView: View {
    var triangle: Path {
        var path = Path()
        path.move(to: CGPoint(x: 50, y: 10))
        path.addLine(to: CGPoint(x: 150, y: 10))
        path.addLine(to: CGPoint(x: 100, y: 110))
        path.closeSubpath()
        return path
    }
    
    var body: some View {
        triangle
            .stroke(.green, lineWidth: 5)
    }
}

実行結果は前のと同じ。

Shapeプロトコルに準拠した定義

Shapeに準拠させることで動的に図形を調整しやすくなるので、実際はこのように使うことが多いかも。

struct CustomShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.closeSubpath()
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            CustomShape()
                .stroke(.green, lineWidth: 5)
                .frame(width: 100, height: 100)
            CustomShape()
                .stroke(.green, lineWidth: 5)
                .frame(width: 200, height: 300)
        }
    }
}

スクリーンショット 2024-10-12 16.27.07.png

基本的な描画操作

Pathの基本操作をまとめる

move(to:) と closeSubpath()

Pathにはsubpathという概念が存在する。
これは1つのPathオブジェクトで複数の図形を描画するために存在し、描画する図形数に応じてPathに複数のsubpathが生成される。

move(to:)は描画開始位置へ移動すると共に新しいsubpathを作成する。
closeSubpath()でsubpathを閉じることができ、更にsubpathの開始位置へ自動的に線を引くため、冒頭の三角を描画するコードでは2回のaddLine(to:)で済んでいる。

        Path { path in
            // 描画開始位置へ移動すると共に新しいsubpathを作成
            path.move(to: CGPoint(x: 50, y: 10))
            // 線を描画
            path.addLine(to: CGPoint(x: 150, y: 10))
            path.addLine(to: CGPoint(x: 100, y: 110))
            // subpathを閉じる
            // 開始位置へ自動的に線を引く
            path.closeSubpath()
        }

addRect(_:transform:)のような形状がそれだけで完結するものに関してはmove(to:)closeSubpath()は不要

線の描画

addLine(to:)

1本の線を描画する

        Path { path in
             path.move(to: CGPoint(x: 10, y: 0))
             path.addLine(to: CGPoint(x: 100, y: 0))
             path.addLine(to: CGPoint(x: 100, y: 100))
             path.addLine(to: CGPoint(x: 10, y: 100))
             path.closeSubpath()
         }
         .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 12.07.30.png

addLines(_:)

複数の線を描画する

        Path { path in
            path.move(to: CGPoint(x: 10, y: 0))
            path.addLines([
                CGPoint(x: 100, y: 0),
                CGPoint(x: 100, y: 100),
                CGPoint(x: 10, y: 100),
                CGPoint(x: 10, y: 0)
            ])
            path.closeSubpath()
        }
        .stroke(.green, lineWidth: 5)

上記コードでは先ほどと異なり最後に始点であるCGpoint(x: 10, y: 10)を指定している。
addLines(_:)ではaddLine(to:)と異なり、最後に始点まで指定しないと思い通りに描画されないので注意。

        Path { path in
            path.move(to: CGPoint(x: 10, y: 0))
            path.addLines([
                CGPoint(x: 100, y: 0),
                CGPoint(x: 100, y: 100),
                CGPoint(x: 10, y: 100),
                // 始点まで指定しないと三角になる
                // CGPoint(x: 10, y: 0)
            ])
            path.closeSubpath()
        }

スクリーンショット 2024-10-13 12.44.01.png

短形の描画

addRect(_:transform:)

1つの短形を描画する。

        Path { path in
            path.addRect(CGRect(x: 10, y: 10, width: 100, height: 200))
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 12.51.56.png

transform引数ではCGAffineTransformで回転・拡大・並行移動・傾斜・反転などができる。デフォルト引数で.identity(単位行列)が指定されている。

addRects(_:transform:)

複数の短形を描画する

        Path { path in
            path.addRects([
                CGRect(x: 10, y: 10, width: 100, height: 200),
                CGRect(x: 150, y: 10, width: 100, height: 200),
            ])
        }

init(roundedRect:cornerRadius:style:)

角丸の短形を描画する

        Path(
            roundedRect: CGRect(x: 10, y: 10, width: 200, height: 100),
            cornerRadius: 10,
            style: .circular
        )
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 21.44.23.png

styleは角丸の形状を示す。

  • circular
    • 直線からカーブにかけてシャープになる
  • continuous
    • 直線からカーブにかけて滑らか。circularより滑らか

↓緑の.circularの角丸と赤の.continuousの角丸をZStackで重ねたもの。circularの方が直線からカーブにかけて.continuousに比べて若干外に出てるが、ぱっと見違いは無さそう。

init(roundedRect:cornerSize:style:)

角の丸みが均一ではない角丸短形を描画できる
1つ前のcornerRadiusでは角の丸みの半径をCGFloatで指定していたので丸みが均一になっていた。このcornerSizeでは角の丸みをCGSizeで指定できるので、楕円形に丸めることが可能になる。

        Path(
            roundedRect: CGRect(x: 10, y: 10, width: 200, height: 100),
            cornerSize: CGSize(width: 20, height: 50),
            style: .circular
        )
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 22.17.46.png

例えばwidthとheightを50, 50にするとcornerRadiusと同じように丸みが均一化される。

スクリーンショット 2024-10-13 22.16.28.png

init(roundedRect:cornerRadii:style:)

4つの角ごとに異なる丸みを指定可能な短形を描画する。

        Path(
            roundedRect: CGRect(x: 10, y: 10, width: 200, height: 100),
            cornerRadii: RectangleCornerRadii(
                topLeading: 10,
                bottomLeading: 20,
                bottomTrailing: 30,
                topTrailing: 40
            ),
            style: .continuous
        )
        .stroke(.green, lineWidth: 5)

addRoundedRect(in:cornerSize:style:transform:)

角丸短形をpathに追加する

        Path { path in
            path.addRoundedRect(
                in: CGRect(x: 10, y: 10, width: 100, height: 200),
                cornerSize: CGSize(width: 30, height: 30)
            )
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 22.43.16.png

addRoundedRect(in:cornerRadii:style:transform:)

角の丸みが均一ではない角丸短形をpathに追加する

        Path { path in
            path.addRoundedRect(in: CGRect(x: 10, y: 10, width: 100, height: 200),
                                cornerRadii: RectangleCornerRadii(
                                    topLeading: 10,
                                    bottomLeading: 20,
                                    bottomTrailing: 30,
                                    topTrailing: 40
                                ))
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 22.46.21.png

楕円の描画

init(ellipseIn:)

楕円のPathを描画
CGRectが正方形の場合は均等な円を描画できる

        VStack {
            Path(ellipseIn: CGRect(x: 10, y: 10, width: 200, height: 100))
                .stroke(.green, lineWidth: 5)
            Path(ellipseIn: CGRect(x: 10, y: 10, width: 200, height: 200))
                .stroke(.green, lineWidth: 5)
        }

スクリーンショット 2024-10-13 23.01.39.png

addEllipse(in:transform:)

Pathに楕円を追加する

        Path { path in
            path.addEllipse(in: CGRect(x: 50, y: 50, width: 200, height: 100))
            path.addEllipse(in: CGRect(x: 50, y: 200, width: 200, height: 200))
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 23.04.38.png

弧の描画

addArc(center:radius:startAngle:endAngle:clockwise:transform:)

弧を描画する

        Path { path in
             path.addArc(
                 center: CGPoint(x: 100, y: 100), // 中心
                 radius: 50, // 半径
                 startAngle: .degrees(40), // 開始角度。時計の4時あたり
                 endAngle: .degrees(180), // 終了角度。時計の9時あたり
                 clockwise: true // 時計回りに描画
             )
         }
         .stroke(Color.green, lineWidth: 5)

スクリーンショット 2024-10-13 23.18.01.png

clockwiseをfalseにすると反時計回りに描画される

スクリーンショット 2024-10-13 23.20.15.png

addArc(tangent1End:tangent2End:radius:transform:)

特定のポイントに向かって弧を描く

        Path { path in
            path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 50))
            path.addArc(
                tangent1End: CGPoint(x: 300, y: 200),
                tangent2End: CGPoint(x: 200, y: 200),
                radius: 100
            )
            path.addLine(to: CGPoint(x: 50, y: 200))
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-13 23.41.55.png

描画開始位置からtangent2Endに向かって弧を描画するが、tangent1Endに向かってカーブを描く。

addRelativeArc(center:radius:startAngle:delta:transform:)

相対的な弧を描画する。
addArc(center:radius:startAngle:endAngle:clockwise:transform:)では開始角度と終了角度を明示的に示したが、addRelativeArcでは開始角度とそこからどの程度描画するかを指定する。

以下の例では40度(時計の4時あたり)から180度分時計回りに描画している。

        Path { path in
            path.addRelativeArc(
                center: CGPoint(x: 100, y: 100),
                radius: 50,
                startAngle: .degrees(40),
                delta: .degrees(180)
            )
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 0.16.33.png

ベジェ曲線の描画

addQuadCurve(to:control:)

2次ベジェ曲線(1つの制御点を持つ曲線)を描画

        Path { path in
            path.move(to: CGPoint(x: 0, y: 100)) // 始点
            path.addQuadCurve(
                to: CGPoint(x: 300, y: 100), // 終点
                control: CGPoint(x: 150, y: 0) // 制御点
            )
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 13.16.57.png

制御点が中央上に来るように配置している。
イメージ的には以下のような感じ。

    var linePath: Path {
        Path { path in
            path.move(to: CGPoint(x: 0, y: 100))
            path.addLine(to: CGPoint(x: 150, y: 0))
            path.addLine(to: CGPoint(x: 300, y: 100))
        }
    }
    
    var controlPath: Path {
        Path { path in
            path.addArc(center: CGPoint(x: 150, y: 0), radius: 5, startAngle: .zero, endAngle: .degrees(360), clockwise: true)
        }
    }
    
    var body: some View {
        ZStack {
            linePath
                .stroke(.red, lineWidth: 5)
            controlPath
                .stroke(.blue, lineWidth: 5)
            // ベジェ曲線
            Path { path in
                path.move(to: CGPoint(x: 0, y: 100)) // 始点
                path.addQuadCurve(
                    to: CGPoint(x: 300, y: 100), // 終点
                    control: CGPoint(x: 150, y: 0) // 制御点
                )
            }
            .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 13.14.58.png

addCurve(to:control1:control2:)

3次ベジェ曲線(2つの制御点を持つ曲線)を描画

        Path { path in
            path.move(to: CGPoint(x: 0, y: 100)) // 始点
            path.addCurve(
                to: CGPoint(x: 300, y: 100), // 終点
                control1: CGPoint(x: 150, y: 0), // 1つ目の制御点
                control2: CGPoint(x: 150, y: 200) // 2つ目の制御点
            )
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 13.10.50.png

2つの制御点は中央の上下に来るように配置している。
イメージ的には以下のような感じ。

    var linePath: Path {
        Path { path in
            path.move(to: CGPoint(x: 0, y: 100))
            path.addLine(to: CGPoint(x: 150, y: 0))
            path.addLine(to: CGPoint(x: 150, y: 200))
            path.addLine(to: CGPoint(x: 300, y: 100))
        }
    }
    
    var controlPath: Path {
        Path { path in
            path.addArc(center: CGPoint(x: 150, y: 0), radius: 5, startAngle: .zero, endAngle: .degrees(360), clockwise: true)
            path.addArc(center: CGPoint(x: 150, y: 200), radius: 5, startAngle: .zero, endAngle: .degrees(360), clockwise: true)
        }
    }
    
    var body: some View {
        ZStack {
            linePath
                .stroke(.red, lineWidth: 5)
            controlPath
                .stroke(.blue, lineWidth: 5)
            // ベジェ曲線
            Path { path in
                path.move(to: CGPoint(x: 0, y: 100)) // 始点
                path.addCurve(
                    to: CGPoint(x: 300, y: 100), // 終点
                    control1: CGPoint(x: 150, y: 0), // 1つ目の制御点
                    control2: CGPoint(x: 150, y: 200) // 2つ目の制御点
                )
            }
            .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 12.48.10.png

move(to:)とcloseSubpath()の重要性

先述した通り、move(to:)で描画開始位置へ移動してsubpathを作成し、closeSubpath()でsubpathを閉じることができる。
例えば以下のようにPath内で2つの図形を描画するとする。

        Path { path in
            // 1つ目の図形
            path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 150))
            path.addLine(to: CGPoint(x: 50, y: 150))
            path.closeSubpath()

            // 2つ目の図形
            path.move(to: CGPoint(x: 200, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 150))
            path.addLine(to: CGPoint(x: 200, y: 150))
            path.closeSubpath()
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 13.38.58.png

もし1つ目の図形でcloseSubpath()しない場合、始点までの線は描画されない。subpathが閉じられないままなので.fillなどで塗りつぶした際に正常に塗りつぶされない可能性がある。

スクリーンショット 2024-10-14 13.42.42.png

今度は2つ目の図形でmove(to:)せずにaddLine(to:)など続けてしまうと、前のsubpathと同じ始点からsubpathが暗黙的に作成される。

        Path { path in
            // 1つ目の図形
            path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 150))
            path.addLine(to: CGPoint(x: 50, y: 150))
            path.closeSubpath()

            // 2つ目の図形
            // path.move(to: CGPoint(x: 200, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 150))
            path.addLine(to: CGPoint(x: 200, y: 150))
            path.closeSubpath()
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 13.49.08.png

1つ目の図形でmove(to:)しない場合、線が描画されない。これはmove(to:)により作成されていたsubpathが存在しないため。

        Path { path in
            // 1つ目の図形
            // path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 150))
            path.addLine(to: CGPoint(x: 50, y: 150))
            path.closeSubpath()

            // 2つ目の図形
            path.move(to: CGPoint(x: 200, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 50))
            path.addLine(to: CGPoint(x: 300, y: 150))
            path.addLine(to: CGPoint(x: 200, y: 150))
            path.closeSubpath()
        }
        .stroke(.green, lineWidth: 5)

スクリーンショット 2024-10-14 13.51.02.png

addRects(_:transform:)などでmove(to:)をしなくていいのは、内部でsubpathの作成とcloseを両方して完結しているため。
Pathの始点と終点を指定する以下3つを使う場合は気をつけよう。

  • addLine(to:)
  • addQuadCurve(to:control:)
  • addCurve(to:control1:control2:)

Path.Element

PathのElementとして以下のcaseがある

  • Path.Element.move(to:)
  • Path.Element.line(to:)
  • Path.Element.curve(to:control1:control2:)
  • Path.Element.quadCurve(to:control:)
  • Path.Element.closeSubpath

forEach(_:)

これらのPath.ElementをforEachで取り出すことができる。
なのでPath内の各要素を取り出して任意の処理を実行することが可能。
例えばmove(to:)した値に合わせて動的にaddLine(to:)の値を調整したりなどできそう。

    let path = Path { path in
        path.move(to: CGPoint(x: 50, y: 50))
        path.addLine(to: CGPoint(x: 150, y: 50))
        path.addLine(to: CGPoint(x: 150, y: 150))
        path.addQuadCurve(to: CGPoint(x: 50, y: 150), control: CGPoint(x: 100, y: 200))
        path.closeSubpath()
    }
    
    func printEachElement() {
        path.forEach { element in
            switch element {
            case .move(let point):
                print("Move to \(point)")
            case .line(let point):
                print("Line to \(point)")
            case .quadCurve(let point, let control):
                print("Quad curve to \(point), control: \(control)")
            case .curve(let point, let control1, let control2):
                print("Curve to \(point), control1: \(control1), control2: \(control2)")
            case .closeSubpath:
                print("Close subpath")
            }
        }
    }
Move to (50.0, 50.0)
Line to (150.0, 50.0)
Line to (150.0, 150.0)
Quad curve to (50.0, 150.0), control: (100.0, 200.0)
Close subpath

Pathの操作

Pathの追加

addPath(_:transform:)で現在のPathに別のPathを追加する。
1つのPathを複数回使用する場合や、複雑な図形を作る時にパーツごとでPathを生成したりなどで使えそう。
以下の例では楕円を1つ描画し、transformを用いて同じ楕円の位置をずらして表示している。

    var ellipsePath: Path {
        Path { path in
            path.addEllipse(in: CGRect(x: 50, y: 50, width: 200, height: 100))
        }
    }
    
    var body: some View {
        Path { path in
            path.addPath(ellipsePath)
            let transform = CGAffineTransform(translationX: 50, y: 50)
            path.addPath(ellipsePath, transform: transform)
        }
        .stroke(Color.green, lineWidth: 2)
    }

スクリーンショット 2024-10-14 15.49.59.png

以下の例では楕円を1つ描画し、transformを用いて同じ楕円の位置をずらして表示している

共通領域の取得

intersection(_:eoFill:)で2つのPathの共通領域を取得する
例えばrectとellipseを同時に描画すると以下のようになるとする

    var rectPath = Path { path in
        path.addRect(CGRect(x: 50, y: 50, width: 100, height: 100))
    }
    
    var ellipsePath = Path { path in
        path.addEllipse(in: CGRect(x: 70, y: 70, width: 100, height: 100))
    }
    
    var body: some View {
        Path { path in
            path.addPath(rectPath)
            path.addPath(ellipsePath)
        }
        .stroke(.green, lineWidth: 5)
    }

スクリーンショット 2024-10-14 15.06.58.png

これにintersection(_:eoFill:)を適用した場合

    var body: some View {
        HStack {
            rectPath
                .intersection(ellipsePath)
                .stroke(.green, lineWidth: 5)
            ellipsePath
                .intersection(rectPath)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.19.50.png

どちらのPathに対して行っても共通領域は同じなので結果も同じ。

非共通領域の取得

subtracting(_:eoFill:)で共通領域ではない部分を取得する。

    var body: some View {
        HStack {
            rectPath
                .subtracting(ellipsePath)
                .stroke(.green, lineWidth: 5)
            ellipsePath
                .subtracting(rectPath)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.19.19.png

共通領域の線を取得

lineIntersection(_:eoFill:)で共通領域の線のみを取得する

    var body: some View {
        HStack {
            rectPath
                .lineIntersection(ellipsePath)
                .stroke(.green, lineWidth: 5)
            ellipsePath
                .lineIntersection(rectPath)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.18.40.png

非共通領域の線を取得

lineSubtraction(_:eoFill:)で共通領域ではない線を取得する。

    var body: some View {
        HStack {
            rectPath
                .lineSubtraction(ellipsePath)
                .stroke(.green, lineWidth: 5)
            ellipsePath
                .lineSubtraction(rectPath)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.18.01.png

normalized(eoFill:)

自己交差しているpathを正規化し、自己交差がないPath(弱単純パス: weakly-simple path)にする。
例えば以下のようなpathは数字の8を描くように自身のlineで交差している。これが自己交差。この状態では正しく塗りつぶしや描画が行われない可能性がある。

    let path = Path { path in
        path.move(to: CGPoint(x: 50, y: 50))
        path.addLine(to: CGPoint(x: 150, y: 150))
        path.addLine(to: CGPoint(x: 50, y: 150))
        path.addLine(to: CGPoint(x: 150, y: 50))
    }

    var body: some View {
        path
            .stroke(.green, lineWidth: 5)
    }

スクリーンショット 2024-10-14 15.31.21.png

そこでnormalized(eoFill:)を行うことで、自己交差しないpathに内部的に変更を加えてくれる。

    var body: some View {
        path
            .normalized()
            .stroke(.green, lineWidth: 5)
    }

見た目的にはcloseSubpath()された(一番上にlineが追加された)ようにしか見えないが、先述したpathの要素をforEachで出力してみると、交差されないようにpathが調整されているのがわかる。

		path.normalized().forEach { element in
        switch element {
        case .move(let point):
            print("Move to \(point)")
        case .line(let point):
            print("Line to \(point)")
				...
				}
		}

.normalized()

Move to (50.0, 50.0)
Line to (150.0, 150.0)
Line to (50.0, 150.0)
Line to (150.0, 50.0)

.normalized()

Move to (100.0, 100.0)
Line to (150.0, 150.0)
Line to (50.0, 150.0)
Line to (100.0, 100.0)
Line to (50.0, 50.0)
Line to (150.0, 50.0)
Close subpath

交差はしてないが(100, 100)で接触はしてるので「完全な単純パス」ではなく「弱単純パス」とドキュメントに示されているのだと思う。

The returned path is a weakly-simple path, has no self-intersections, and has a normalized orientation.
https://developer.apple.com/documentation/swiftui/path/normalized(eofill:)

対象差の取得

symmetricDifference(_:eoFill:)でどちらか一方に含まれるが、両方には含まれない領域、つまり対象差を取得する。

    var body: some View {
        HStack {
            rectPath
                .symmetricDifference(ellipsePath)
                .fill(.green)
            ellipsePath
                .symmetricDifference(rectPath)
                .fill(.green)
        }
    }

スクリーンショット 2024-10-14 15.37.03.png

領域の合成

union(_:eoFill:)で2つの領域を合成する

    var body: some View {
        HStack {
            rectPath
                .union(ellipsePath)
                .fill(.green)
            ellipsePath
                .union(rectPath)
                .fill(.green)
        }
    }

スクリーンショット 2024-10-14 15.37.43.png

strokeで見るとPathが合成されているのがわかる。

    var body: some View {
        HStack {
            rectPath
                .union(ellipsePath)
                .stroke(.green, lineWidth: 5)
            ellipsePath
                .union(rectPath)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.38.47.png

PathのTransform

applying(_:)

Pathに対してCGAffineTransformを適用する。

    var roundedRect: Path {
        Path(roundedRect: CGRect(x: 10, y: 10, width: 100, height: 100), cornerRadius: 10)
    }
    
    var body: some View {
        ZStack {
            roundedRect
                .stroke(.green, lineWidth: 5)
            roundedRect
                .applying(CGAffineTransform(translationX: 200, y: 0)) // 200pt移動
                .applying(CGAffineTransform(rotationAngle: .pi / 4)) // 45度回転
                .applying(CGAffineTransform(scaleX: 1, y: 1.5)) // 縦に1.5倍
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.55.38.png

offsetBy(dx:dy:)

Pathを並行移動する

    var body: some View {
        ZStack {
            roundedRect
                .stroke(.green, lineWidth: 5)
            roundedRect
                .offsetBy(dx: 50, dy: 50)
                .stroke(.green, lineWidth: 5)
        }
    }

スクリーンショット 2024-10-14 15.57.04.png

trimmedPath(from: to:)

Pathの一部を切り取る。
切り取る開始位置(from)から終了位置(to)に0~1.0の値を指定することで、Path全体の長さに対して相対的に切り取れる。
以下の例では短形の0.1~1.0の部分のみを切り取って表示している

    var body: some View {
        Path(roundedRect: CGRect(x: 10, y: 10, width: 100, height: 100), cornerRadius: 10)
            .trimmedPath(from: 0.1, to: 1.0)
            .stroke(.green, lineWidth: 5)
    }

スクリーンショット 2024-10-14 16.00.29.png

Pathのプロパティ

boundingRect

Pathが描画される領域の最小の短形を返す。
以下のコードではPathで描画した四角(ひし形)の短形をCGRectで返している

    var rect = Path { path in
        path.move(to: CGPoint(x: 50, y: 0))
        path.addLine(to: CGPoint(x: 100, y: 50))
        path.addLine(to: CGPoint(x: 50, y: 150))
        path.addLine(to: CGPoint(x: 0, y: 50))
        path.closeSubpath()
    }
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            rect
                .stroke(Color.green, lineWidth: 5)
            VStack {
                Text("\(rect.boundingRect.width)")
                Text("\(rect.boundingRect.height)")
            }
        }
    }

スクリーンショット 2024-10-14 17.14.39.png

cgPath

PathからcgPathを取得できるのでCoreGraphicsベースの描画にも使える

    var rect: Path {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 0))
             ...
            path.closeSubpath()
        }
    }
    
    var cgPath: CGPath {
        rect.cgPath
    }

contains(_:eoFill:)

CGPointで指定した位置がPathの内部に含まれているかを返す
eoFillがtrueの場合は偶奇ルール、falseの場合は非ゼロワインディングルールを適用して判定をする。詳細は後述する。

    var rect: Path {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 0))
             ...
            path.closeSubpath()
        }
    }
    
    var isInside: Bool {
        let point = CGPoint(x: 50, y: 50)
        return rect.contains(point, eoFill: true)
    }

currentPoint

Pathの最後に描かれたポイントを示す。
以下の例だと(x: 100, y: 50)を最後にcurrentPointしているので(x: 100, y: 50)が出力される。条件に基づいてPathの描画を動的に変更する場合などに使えたり、Pathの描画を途中でやめて再開する場合にも使えそう。

    var rect: Path {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 0))
            path.addLine(to: CGPoint(x: 100, y: 50))
            print("\(path.currentPoint)") // (x: 100, y: 50)が出力
            ...
        }
    }

isEmpty

Pathに線や図形が何もない場合はtrue

Pathへのstyle適用

stroke(_:lineWidth:)

Pathの中心に対して線を描画する

        Path(roundedRect: CGRect(x: 10, y: 10, width: 100, height: 100), cornerRadius: 20)
        .stroke(.green, lineWidth: 5)

stroke(style:)

Pathに対して線のスタイルを適用する

        Path { path in
            path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 150))
            path.addLine(to: CGPoint(x: 50, y: 150))
            path.closeSubpath()
        }
        .stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round, dash: [10, 5]))

スクリーンショット 2024-10-14 17.34.33.png

strokedPath(_:)

Pathに対してStrokeStyle(線のスタイル)を適用する。

    var rect: Path {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 50))
            path.addLine(to: CGPoint(x: 150, y: 150))
            path.addLine(to: CGPoint(x: 50, y: 150))
            path.closeSubpath()
        }
        .strokedPath(StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round, dash: [10, 5]))
    }

見た目としてはstroke(style:)と同じ。

stroke(style:)との違いは、stroke(style:)はshapeのモディファイアなのに対し、strokedPath(_:)はPathのモディファイアであること。つまりstroke(style:)を使用した場合Shapeとして返すが、strokedPath(_:)はPathを返してくれる。

strokedPath(_:)を使用すると、実際のsubpathの内部も変更される。
例えば上の例でstrokedPath(_:)を適用する前は以下のようなpathだったが、

Move to (50.0, 50.0)
Line to (150.0, 50.0)
Line to (150.0, 150.0)
Line to (50.0, 150.0)
Close subpath

適用後は以下のようになる。

Move to (50.0, 52.5)
Line to (60.0, 52.5)
Curve to (62.5, 50.0), control1: (61.38071187457698, 52.5), control2: (62.5, 51.38071187457698)
Curve to (60.0, 47.5), control1: (62.5, 48.61928812542302), control2: (61.38071187457698, 47.5)
Line to (50.0, 47.5)
Curve to (47.5, 50.0), control1: (48.61928812542302, 47.5), control2: (47.5, 48.61928812542302)
Curve to (50.0, 52.5), control1: (47.5, 51.38071187457698), control2: (48.61928812542302, 52.5)
Close subpath
Move to (65.0, 52.5)
Line to (75.0, 52.5)
Curve to (77.5, 50.0), control1: (76.38071187457699, 52.5), control2: (77.5, 51.38071187457698)
...

なので例えば破線のような図形を描画したい場合、addLine(to:)を繰り返すのではなくstrokedPath(_:)を使うことで1本のLineだけで完結できる。

stroke(style:)の適用後は各pathごとに取得できないので内部的なpathがどうなっているのかは不明。

fill(_:)

塗りつぶす

        Path { path in
            ...
        }
        .fill(.green)

fill(_:style:)

塗りつぶしのstyle(ルール)を適用できる

        HStack {
             // 奇遇ルール
             Path { path in
                 path.addRect(CGRect(x: 50, y: 50, width: 100, height: 100))
                 path.addRect(CGRect(x: 75, y: 75, width: 100, height: 100))
             }
             .fill(Color.blue, style: FillStyle(eoFill: true))

             // 非ゼロワインディングルール
             Path { path in
                 path.addRect(CGRect(x: 50, y: 50, width: 100, height: 100))
                 path.addRect(CGRect(x: 75, y: 75, width: 100, height: 100))
             }
             .fill(Color.red, style: FillStyle(eoFill: false))
         }

スクリーンショット 2024-10-14 17.30.34.png

foregroundStyle(_:)

お馴染みのforegroundStyle(_:)も使えるが、グラデーションする際は.frameで指定する必要がありそう。

            Path(roundedRect: CGRect(x: 10, y: 10, width: 100, height: 100), cornerRadius: 20)
            .foregroundStyle(LinearGradient(
                gradient: Gradient(colors: [.red, .green]),
                startPoint: .top,
                endPoint: .bottom
            ))
            .frame(width: 100, height: 100)

スクリーンショット 2024-10-14 18.07.57.png

.degreesと.radians

どこから開始するのかわからなくなるのでメモ

        Path { path in
            path.addArc(
                center: CGPoint(x: 50, y: 50),
                radius: 30,
                startAngle: .zero,
                endAngle: .degrees(90),
                clockwise: false
            )
        }
        .stroke(.green, lineWidth: 5)

上記コードのようにclockwise: falseの場合、endAngleでそれぞれ以下を指定した場合の結果をまとめる。

.degrees(90) .degrees(180) .degrees(270) .degrees(360)
スクリーンショット 2024-10-14 18.27.31.png スクリーンショット 2024-10-14 18.27.46.png スクリーンショット 2024-10-14 18.28.00.png スクリーンショット 2024-10-14 18.28.12.png
.radians(.pi * 0.5) .radians(.pi) .radians(.pi * 1.5) .radians(.pi * 2)
スクリーンショット 2024-10-14 18.27.31.png スクリーンショット 2024-10-14 18.27.46.png スクリーンショット 2024-10-14 18.28.00.png スクリーンショット 2024-10-14 18.28.12.png

eoFill

Pathの引数として何回か出てきてるが、これは多角形の内部・外部の判定をする際に使用するルールを指定するためのパラメータ。

  • true:偶奇ルール(Even-Odd Rule)
  • false:非ゼロワインディングルール(Non-Zero Winding Rule)

ルールによっては特定の点が内部・外部と変わる可能性がある。つまり図形を塗りつぶす際にルールによって塗り潰されたりされなかったりする。

偶奇ルール(Even-Odd Rule)

このルールでは、一方向に引いた直線とPathの交差回数が偶数奇数かを考慮して決定する。

  1. 判定したい点から任意の方向に無限に伸びる直線を引く
  2. その直線がPathと交差する回数を数える
  3. 偶数回交差する場合、その領域は外部。奇数回交差する場合、その領域は内部

偶奇ルール.png

非ゼロワインディングルール(Non-Zero Winding Rule)

このルールでは、一方向に引いた直線を横切るPathの方向回数を考慮して決定する。ワインディング数はある領域を基準として、多角形がその領域の周りを何回巻いているかを示す整数値。

  1. 判定したい点から任意の方向に無限に伸びる直線を引く
  2. その直線と交差するPathが下から上に交差する場合、ワインディング数を+1する。上から下に交差する場合、ワインディング数を-1する
  3. ワインディング数が0でない場合、その領域は多角形の内部、0の場合は外部

非ゼロ.png

    var star = Path { path in
        path.move(to: CGPoint(x: 100, y: 10))
        path.addLine(to: CGPoint(x: 150, y: 180))
        path.addLine(to: CGPoint(x: 10, y: 80))
        path.addLine(to: CGPoint(x: 190, y: 80))
        path.addLine(to: CGPoint(x: 50, y: 180))
        path.closeSubpath()
    }
    
    var body: some View {
        HStack {
            star
            .fill(.yellow, style: FillStyle(eoFill: true))
            star
                .fill(.yellow, style: FillStyle(eoFill: false))
        }
    }

スクリーンショット 2024-10-14 18.46.47.png

    // 内側も外側も両方とも時計回りに描画したarc
    var arc = Path { path in
        path.addArc(center: CGPoint(x: 100, y: 100), radius: 50, startAngle: .zero, endAngle: .radians(.pi * 2), clockwise: true)
        path.addArc(center: CGPoint(x: 100, y: 100), radius: 100, startAngle: .zero, endAngle: .radians(.pi * 2), clockwise: true)
        path.closeSubpath()
    }
    // 内側は反時計回り、外側は時計回りに描画したarc
    var arc2 = Path { path in
        path.addArc(center: CGPoint(x: 100, y: 100), radius: 50, startAngle: .zero, endAngle: .radians(.pi * 2), clockwise: false)
        path.addArc(center: CGPoint(x: 100, y: 100), radius: 100, startAngle: .zero, endAngle: .radians(.pi * 2), clockwise: true)
        path.closeSubpath()
    }
    
    var body: some View {
        HStack {
            VStack {
                arc
                    .fill(.yellow, style: FillStyle(eoFill: true))
                arc
                    .fill(.yellow, style: FillStyle(eoFill: false))
            }
            VStack {
                arc2
                    .fill(.yellow, style: FillStyle(eoFill: true))
                arc2
                    .fill(.yellow, style: FillStyle(eoFill: false))
            }
        }
    }

スクリーンショット 2024-10-14 18.46.06.png

おわりに

現時点(2024年10月)で出ているPathのAPIを全てまとめてみました。こうやって全て試してみると意外となんでも作れそうな気がします。
ドキュメントを全て見たところ、PathはSwiftUI登場のiOS13からありますが、その後追加されたAPIはiOS17でごく一部なのであまり進化がないようです。また、WWDCのセッションでもPathについて解説してるものもなく、SwiftUI登場時のセッションで軽く触れられている程度でした。
間違いなどあればコメントいただけると助かります。

参考資料

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?