LoginSignup
8
7

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-05

はじめに

昔の記事で、UIKit で星型の図形を作ってみたことがあったのですが、勉強ついでに SwiftUI でも試してみました。次のようなビューを作成してみます。

正五角形 正六角形
Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.46.56.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.38.png
正七角形 正八角形
Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.52.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.48.10.png

環境

  • Xcode Version 11.5 (11E608c)

実装方針

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

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

あとは、取得した頂点を直線で結ぶだけです。正五角形の場合をアニメーションで表すとこんな感じ。
star_outline.gif

基本的なデータ型を実装する

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

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 は極座標系における1点を表す構造体です。半径 radius と角度 angle をプロパティとして所持しています。

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

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

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

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)
        /// 1つ目の頂点
        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 によって、平面上の点群を取得できるように実装しています。
たとえば、ある矩形領域内の中央に配置した星型の図形の頂点のリストは次のようにして取得することができます。

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 を返す実装を要求します。

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

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

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

move(to:)

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

addLine(to:)

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

closeSubpath()

最後に経路を閉じます。

実装例

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

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 でプレビューすることもできます。

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

プレビューによって、次のように表示されます。
Screen Shot 2020-07-04 at 23.11.55.png
プレビューでは、Attribute Inspector でプロパティを変更できて、変更ごとに図形が更新されるので面白いです。
StarShape.mov.gif

内部の塗りつぶし

StarShape を Shape として実装したことで、利用する側で自由に図形内部を塗りつぶすことができます。
塗りつぶしには、Shapefill を使用します。

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)
    }
}

PreviewProviderGroup を使って、まとめて表示確認ができて便利です。

#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

こんな感じでプレビューされます。
Screen Shot 2020-07-05 at 13.38.34.png

内部と枠線の塗りつぶし

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

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

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

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

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

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 を適当に設定してプレビューしてみます。
Screen Shot 2020-07-05 at 14.47.58.png
実装はこんな感じです。

#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
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 で遊んでみて色々と試していきたいところです。

8
7
1

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
8
7