SwiftUI で星型のシェイプを作ってみる

## はじめに

[昔の記事](https://qiita.com/takehito-koshimizu/items/1b4e138458c29a970bc1)で、UIKit で星型の図形を作ってみたことがあったのですが、勉強ついでに SwiftUI でも試してみました。次のようなビューを作成してみます。

|正五角形|正六角形|
| ------- | ------- |
| <img width="375" alt="Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.46.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/c6c1b613-69fd-b339-0536-7faa8a6f126d.png"> | <img width="375" alt="Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/08bd59fd-f620-8af4-3f7f-fe3c8df4c196.png"> |

|正七角形|正八角形|
| ------- | ------- |
| <img width="375" alt="Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/ca00efde-48fa-67fc-d755-e3208fb54c29.png"> | <img width="375" alt="Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.48.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/fd5039da-aedd-20e6-52d8-2d4210589da0.png"> |

### 環境

- Xcode Version 11.5 (11E608c)

## 実装方針

星型の図形は、次の方法で作図します。

1. **星型の多角形の外接円と内接円を作る**
1. **外接正多角形の頂点の数 n に対して、円を 2n 等分の扇形に分割する直線を引く**
1. **直線と外接円の交点、内接円の交点を交互に取得する**

あとは、取得した頂点を直線で結ぶだけです。正五角形の場合をアニメーションで表すとこんな感じ。
![star_outline.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/e6db72fe-69a4-98e9-18bb-87a5ac1c5a2b.gif)
## 基本的なデータ型を実装する

まず、SwiftUI に依存しないデータ構造を実装します。この準備をすることで、座標の位置計算など、SwiftUI フレームワークに依存しない処理を分離することができます。

```swift:PolarCoordinate.swift
import CoreGraphics

/// 極座標
struct PolarCoordinate {
    /// 半径
    var radius: Double
    /// 角度（ 弧度法 ）
    var angle: Double

    init(radius: Double = 1, angle: Double = 0) {
        self.radius = abs(radius)
        self.angle = angle / (2 * .pi)
    }
}

extension PolarCoordinate {
    /// CoreGraphics の座標系における点
    var cgPoint: CGPoint {
        CGPoint(x: radius * cos(angle), y: radius * sin(angle))
    }
}

extension PolarCoordinate {
    /// 回転
    func rotated(by angle: Double) -> Self {
        var point = self
        point.angle += angle
        return point
    }

    /// 拡大・縮小
    func scaled(by t: Double) -> Self {
        var point = self
        point.radius *= t
        return point
    }
}
```

`PolarCoordinate` は極座標系における１点を表す構造体です。半径 `radius` と角度 `angle` をプロパティとして所持しています。

最終的には、平面上の点として扱うことになるので、次のように、半径と角度から `CGPoint` を算出できるようにします。

```swift
var cgPoint: CGPoint {
    CGPoint(x: radius * cos(angle), y: radius * sin(angle))
}
```

星型の図形は多角形なので、極座標の点の集まりと、それを任意の位置に平行移動させるための中心座標の組として表現します。次のような構造体 `StarParameters` として実装します。

```swift:StarParameters.swift
import CoreGraphics

/// 星の頂点を表現した構造体
struct StarParameters {
    /// 中心座標
    private(set) var center: CGPoint = .zero
    /// 極座標系の点群
    private(set) var points: [PolarCoordinate]

    /// CoreGraphics の座標系における点群に変換する
    var cgPoints: [CGPoint] {
        let translatedByCenter = { [center] (point: CGPoint) in
            CGPoint(x: point.x + center.x, y: point.y + center.y)
        }
        return points.lazy.map(\.cgPoint).map(translatedByCenter)
    }
}

extension StarParameters {
    /// 単位円周上で初期化する初期化子
    ///
    /// - Parameters:
    ///   - vertex: 外側の頂点の数
    ///   - smoothness: 外接円と内接円の半径の比率
    init(vertex: UInt = 5, smoothness: Double = 0.5) {
        /// 内側も含めた頂点
        let offests = (1...(2 * vertex))
        /// 中心角
        let rotation = (2 * .pi)/Double(offests.count)
        /// １つ目の頂点
        let start = PolarCoordinate()

        self.points = offests.reduce(into: [start]) { result, offest in
            var last = result.last!
            last.angle += rotation
            last.radius = (offest % 2 == 1 ? smoothness : 1)
            result += [last]
        }
    }
}

extension StarParameters {
    /// 中心座標を設定します
    func center(x: CGFloat, y: CGFloat) -> Self {
        var parameters = self
        parameters.center = CGPoint(x: x, y: y)
        return parameters
    }

    /// 半径を設定します
    func radius(_ radius: CGFloat) -> Self {
        var parameters = self
        parameters.points = points.map { $0.scaled(by: Double(radius)) }
        return parameters
    }

    /// 回転角を設定します
    func rotated(by angle: CGFloat) -> Self {
        var parameters = self
        parameters.points = points.map { $0.rotated(by: Double(angle)) }
        return parameters
    }
}
```

`cgPoints` によって、平面上の点群を取得できるように実装しています。
たとえば、ある矩形領域内の中央に配置した星型の図形の頂点のリストは次のようにして取得することができます。

```swift
func starPoints(in rect: CGRect) -> [CGPoint] {
    return StarParameters(vertex: 5, smoothness: 0.5) // 頂点の数, 滑らかさ
        .center(x: rect.midX, y: rect.midY)    // 内接円の中心
        .radius(min(rect.midX, rect.midY))     // 内接円の半径
        .rotated(by: -.pi/2)                   // 回転
        .cgPoints
}
```

この CGPoint のリスト要素に対して、逐次、直線を引くことによって星型の図形を作図することができます。

ちなみに、これらのデータ構造は CoreGraphics フレームワークにのみ依存した実装なので、SwiftUI だけでなく UIKit でも再利用することができます。UIKit では、UIBezierPath で描画する場合の処理簡易化のために利用すると良いでしょう。

## SwiftUI

### Shape と Path

図形を SwiftUI のビューとして扱う場合は、`Shape` プロトコルに適合します。`Shape` は、与えた矩形に対して `Path` を返す実装を要求します。

```swift:SwiftUI
public protocol Shape : Animatable, View {
    func path(in rect: CGRect) -> Path
}
```

ここで星型の `Path` を作成することになります。`Path` は図形の経路を表現した構造体です。

星型のような多角形は、直線で囲まれた閉じた経路として表現されるので、次の手順で作成します。

#### move(to:)

まず、図形の開始地点の頂点に移動します。

#### addLine(to:)

次に、次の頂点に向かって、逐次、線を引いていきます。

#### closeSubpath()

最後に経路を閉じます。

#### 実装例

実際の実装は次の通りです。

```swift:StarShape.swift
import SwiftUI

/// 星型のシェイプ
struct StarShape: Shape {
    /// 頂点の数
    var vertex: UInt = 5
    /// 滑らかさ
    var smoothness: Double = 0.5
    /// 回転角
    var rotation: CGFloat = -.pi/2

    func path(in rect: CGRect) -> Path {
        Path { path in
            let points: [CGPoint] = starPoints(in: rect)
            path.move(to: points.first!)
            points.forEach { point in
                path.addLine(to: point)
            }
            path.closeSubpath()
        }
    }

    /// 星型の頂点のリスト
    ///
    /// - Parameter rect: 星型の外接円が内接する矩形領域
    private func starPoints(in rect: CGRect) -> [CGPoint] {
        return StarParameters(vertex: vertex, smoothness: smoothness)
            .center(x: rect.midX, y: rect.midY)
            .radius(min(rect.midX, rect.midY))
            .rotated(by: rotation)
            .cgPoints
    }
}
```

Shape は View のサブタイプなので、`PreviewProvider` でプレビューすることもできます。

```swift
#if DEBUG
struct StarPath_Previews: PreviewProvider {
    static var previews: some View {
        StarShape(vertex: 5, smoothness: 0.5)
    }
}
#endif
```

プレビューによって、次のように表示されます。
<img width="1792" alt="Screen Shot 2020-07-04 at 23.11.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/2a3ac60c-1a7a-73b8-8e0f-7f2f07f5ca2f.png">
プレビューでは、Attribute Inspector でプロパティを変更できて、変更ごとに図形が更新されるので面白いです。
![StarShape.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/2d3c55af-9247-84bf-cc45-27e3472cf1d4.gif)
### 内部の塗りつぶし
StarShape を `Shape` として実装したことで、利用する側で自由に図形内部を塗りつぶすことができます。
塗りつぶしには、`Shape` の `fill` を使用します。

```swift:StarView.swift
import SwiftUI

/// 星型のビュー
struct StarView<Style: ShapeStyle>: View {
    /// 頂点の数
    var vertex: UInt
    /// 滑らかさ
    var smoothness: Double
    /// 塗りつぶし
    var style: Style

    var body: some View {
        StarShape(vertex: vertex, smoothness: smoothness)
            .fill(style)
            .aspectRatio(1, contentMode: .fit)
    }
}
```

`PreviewProvider` は `Group` を使って、まとめて表示確認ができて便利です。

```swift
#if DEBUG
struct StarView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            StarView(vertex: 5, smoothness: 0.5, style: Color(red: 0, green: 1, blue: 1))
            StarView(vertex: 6, smoothness: 0.6, style: Color(red: 1, green: 0, blue: 1))
            StarView(vertex: 7, smoothness: 0.7, style: LinearGradient(gradient: .init(colors: [.pink, .orange]),
                startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 0.6)))
            StarView(vertex: 8, smoothness: 0.8, style: RadialGradient(gradient: .init(colors: [.purple, .yellow]),
                center: .center, startRadius: 0, endRadius: 75))
        }
        .previewLayout(.fixed(width: 160, height: 160))
    }
}
#endif
```

こんな感じでプレビューされます。
<img width="1792" alt="Screen Shot 2020-07-05 at 13.38.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/8bdfd379-ff91-1166-db35-004c51dc74ba.png">
### 内部と枠線の塗りつぶし

さて、Shape は内部の塗りつぶしだけでなく `stroke` を使って、枠線を塗りつぶす事もできます。

```swift
StarShape(vertex: vertex, smoothness: smoothness)
    .stroke(style, lineWidth: 4)
    .aspectRatio(1.1, contentMode: .fit)
```

しかし、`fill` と `stroke` は同時にはできません。`fill` や `stroke` は `Shape` を返さずに `View` を返します。`View` は `Shape` と異なり、`fill` や `stroke` メソッドを持っていないため、これが原因でコンパイルエラーになります。

```swift
StarShape(vertex: vertex, smoothness: smoothness)
    .fill(style1)
    .stroke(style2, lineWidth: 4) // コンパイルエラー
    .aspectRatio(1.1, contentMode: .fit)
```

この場合は、`fill` と `stroke` で別の View を作成して、それを重ね合わせて表示させると良さそうです。`ZStack` を使って重ね合わせます。

```swift
struct StarView2: View {
    /// 頂点の数
    var vertex: UInt = 5
    /// 滑らかさ
    var smoothness: Double = 0.5
    /// 内部の塗りつぶし
    var fill: LinearGradient
    /// 枠線の塗りつぶし
    var stroke: LinearGradient

    var body: some View {
        let shape = StarShape(vertex: vertex, smoothness: smoothness)
        return ZStack {
            shape.fill(fill)
            shape.stroke(stroke, lineWidth: 4)
        }
        .aspectRatio(1.1, contentMode: .fit)
    }
}
```

枠線と内部の塗りつぶす `LinearGradient` を適当に設定してプレビューしてみます。
<img width="1792" alt="Screen Shot 2020-07-05 at 14.47.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/5b1b4d3e-365c-f57d-4753-f8ddd183ebb3.png">
実装はこんな感じです。

```swift
#if DEBUG
struct StarView2_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            StarView2(vertex: 5, smoothness: 0.5, fill: Styles.style1, stroke: Styles.style2)
            StarView2(vertex: 6, smoothness: 0.6, fill: Styles.style2, stroke: Styles.style3)
            StarView2(vertex: 7, smoothness: 0.7, fill: Styles.style3, stroke: Styles.style2)
            StarView2(vertex: 8, smoothness: 0.8, fill: Styles.style4, stroke: Styles.style4)
        }
        .previewLayout(
            PreviewLayout.fixed(width: 160, height: 160)
        )
    }
}
#endif
```

```swift:Styles.swift
import SwiftUI

enum Styles {}

extension Styles {
    static var style1: LinearGradient {
        LinearGradient(
            gradient: Gradient(colors: [
                Color(red: 239/255, green: 120.0/255, blue: 221/255),
                Color(red: 239/255, green: 172.0/255, blue: 120/255)
            ]),
            startPoint: UnitPoint(x: 0.5, y: 0),
            endPoint: UnitPoint(x: 0.5, y: 0.6)
        )
    }

    static var style2: LinearGradient {
        LinearGradient(
            gradient: Gradient(colors: [
                Color(red: 90/255, green: 177/255, blue: 187/255),
                Color(red: 247/255, green: 221/255, blue: 114/255)
            ]),
            startPoint: UnitPoint(x: 0.5, y: 0),
            endPoint: UnitPoint(x: 0.5, y: 0.6)
        )
    }

    static var style3: LinearGradient {
        LinearGradient(
            gradient: Gradient(colors: [
                Color(red: 14/255, green: 107/255, blue: 168/255),
                Color(red: 166/255, green: 225/255, blue: 250/255)
            ]),
            startPoint: UnitPoint(x: 0.5, y: 0),
            endPoint: UnitPoint(x: 0.0, y: 0.7)
        )
    }

    static var style4: LinearGradient {
        LinearGradient(
            gradient: Gradient(colors: [
                Color(red: 155/255, green: 120/255, blue: 116/255),
                Color(red: 211/255, green: 62/255, blue: 67/255)
            ]),
            startPoint: UnitPoint(x: 0.5, y: 0),
            endPoint: UnitPoint(x: 0.5, y: 1)
        )
    }
}

#if DEBUG
struct Styles_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            Styles.style1.previewDisplayName("style1")
            Styles.style2.previewDisplayName("style2")
            Styles.style3.previewDisplayName("style3")
            Styles.style4.previewDisplayName("style4")
        }
        .previewLayout(
            PreviewLayout.fixed(width: 100, height: 100)
        )
    }
}
#endif
```

## 感想

プレビューしながら実装の内容が確認できるので、快適に実装できるように感じました。
普段はインターフェースビルダーを使うことが多いのですが、慣れてこれば SwiftUI の方が生産性が高くなりそうです。

UIKit と異なり、Path が構造体であることも印象が良かったです。
参照渡しだと、経路の変更が外部から容易に可能であるのに対して、値渡しならその心配もなくなるので、今回のようなサンプル実装だけでなく、実際に応用する際にハマりが少なくなりそうだと思いました。

iOS 14 からは、WidgetKit の登場によって、SwiftUI の導入が本格化されていくので、引き続き SwiftUI で遊んでみて色々と試していきたいところです。