iOS
CoreGraphics
UIKit
Swift

iOS で星型の図形を UIBezierPath で作る

はじめに

動機づけ

AppSore アプリなどでよく見かける星型の図形を UIBezierPath を使って作りたいと思います。

apple_star.PNG

もちろん、星型のアイコン画像を使えば瞬殺ですが、
コードで作ることで柔軟性が増すはずです。

ということで、UIBezierPath を使って星型の図形を作ってみます。

環境

  • Xcode 9.3.1
  • Apple Swift version 4.1

UIBezierPath で使うAPI

星型多角形は、10本の辺で作られる多角形です。
なので、UIBezierPath で必要なAPIは3つだけ です。

move(to point: CGPoint)

パラメータの点に移動します。
線を引く際の始点になるので最初に呼びます。

addLine(to point: CGPoint)

現在の点からパラメータの点へ直線を引きます。

close()

始点と終点を直線で結んで閉じたパスにします。
閉じたパスにするために最後に呼びます。

UIBezierPath で多角形のパスを作る

頂点の CGPoint を順次適切に与えて直線を引くことで、多角形のパスを作ることができます。

順次 addLine できるような頂点のリストがあれば、
下記のような実装で UIBezierPath のオブジェクトを作ることが出来ます。

extension UIBezierPath {

    /// 多角形を表現した閉じたパスを作る
    ///
    /// - Parameter vertexes: 頂点のリスト(逐次、直線が引けるようにソート済み)
    static func polygon(_ vertexes: [CGPoint]) -> UIBezierPath {
        let path = UIBezierPath()
        /// 最初の頂点へ移動
        path.move(to: vertexes.first!)
        /// 頂点から頂点へ線を引く
        vertexes.forEach { path.addLine(to: $0) }
        /// 曲線を閉じる
        path.close()

        return path
    }
}

ということで、あとは星型多角形の頂点を作れば図形は完成です。

頂点を作るためのアプローチ

下記のような手順を踏むことで頂点を取得します。

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

あとは、取得した頂点を直線で結ぶだけです。
n = 5 の場合をアニメーションで表すとこんな感じです。

star.gif

実際に UIView 上などで扱う場合には、表示したい矩形領域の CGRect を与えて、
矩形領域に内接する円を、星型多角形の外接円にするなどすれば良いでしょう。

星型多角形の内接円の半径は、外接円に対してどの程度の大きさにすればよいのか?
という問題もありますが、逆に内接円の半径の大きさをパラメータ化してしまえば、
さまざまな星型の図形が作れるようになるので面白そうです。

星型多角形の頂点を作る

上述の内容をコードで表現するとこんな感じです。

extension Array where Element==CGPoint {

    /// 星型正多角形の頂点
    ///
    /// - Parameters:
    ///   - radius: 外接円の半径
    ///   - center: 中心の座標
    ///   - roundness: 外接円と内接円の比(min:0, max:1)
    ///   - vertexes: 外接正多角形の頂点の数
    static func starVertexes(radius: CGFloat, center: CGPoint, roundness: CGFloat, numberOfVertexes vertexes: Int) -> [CGPoint] {
        let vertexes = (vertexes * 2)
        return [Int](0...vertexes).map { offset in
            let r = (offset % 2 == 0) ? radius : roundness * radius 
            let θ = CGFloat(offset)/CGFloat(vertexes) * (2 * CGFloat.pi) - CGFloat.pi/2
            return CGPoint(x: r * cos(θ) + center.x, y: r * sin(θ) + center.y)
        }
    }
}

このコードの - CGFloat.pi/2 の部分は UIKit で使うことを前提とした実装になっていますのでご注意ください。
CoreGraphics の座標系は UIKit と y軸が反転するので、星型の頂点の最上部は -π/2 です。
macOS などを考慮する場合は、- CGFloat.pi/2 の部分をパラメータにして回転可能にすればOKです。

さて、UIKit で扱う場合には、外接円の半径を渡すのではなく、
表示したい矩形領域の CGRect や、矩形領域に対するパディングが設定できた方が扱いやすいでしょう。
そのようなメソッドも用意しておきます。

extension Array where Element==CGPoint {

    /// 星型正多角形の頂点
    ///
    /// - Parameters:
    ///   - frame: 外接する矩形
    ///   - padding: パディング
    ///   - roundness: 内接円と半径の外接円の半径の比(パーセント, min:0, max:100)
    ///   - vertexes: 外接正多角形の頂点の数
    static func starVertexes(in frame: CGRect, padding: CGFloat, roundness: CGFloat, numberOfVertexes vertexes: Int) -> [CGPoint] {
        return starVertexes(
            radius: Swift.min(frame.width-padding, frame.height-padding)/2,
            center: CGPoint(x: frame.midX, y: frame.midY),
            roundness: Swift.max(Swift.min(roundness, 100), 0)/100,
            numberOfVertexes: Swift.max(vertexes, 2))
    }

    // 上述のメソッド
    static func starVertexes(radius: CGFloat, center: CGPoint, roundness: CGFloat, numberOfVertexes vertexes: Int) -> [CGPoint]
}

これで星型の図形を UIBezierPath で作成することができます。

UIView に星型を表示してみる

上述の実装を使って、実際にカスタムビューに星を表示してみます。
動作のための完全なコードは下記の通りです。

StarView.swift
import UIKit

// MARK: - StarView
/// ★を表示するカスタムビュー
@IBDesignable
public final class StarView: UIView {

    // MARK: -

    /// ★の塗りつぶしの色
    @IBInspectable public var fillColor: UIColor = UIColor.black
    /// ★の枠線の色
    @IBInspectable public var strokeColor: UIColor = UIColor.black
    /// ★の丸み
    @IBInspectable public var roundness: CGFloat = 40
    /// ★の角の数
    @IBInspectable public var vertexes: Int = 5
    /// ★のパディング
    @IBInspectable public var padding: CGFloat = 0
    /// ★の枠線
    @IBInspectable public var lineWidth: CGFloat = 0

    // MARK: -

    override public func draw(_ rect: CGRect) {
        // 星型のパスを作成する
        let starPath = UIBezierPath
            .polygon(.starVertexes(
                in: rect,
                padding: padding,
                roundness: roundness,
                numberOfVertexes: vertexes))
            .configure(
                lineWidth: lineWidth)

        // 星を塗りつぶす
        fillColor.setFill()
        starPath.fill()

        // 星に枠線を引く
        strokeColor.setStroke()
        starPath.stroke()
    }
}


// MARK: - Array<CGPoint>
private extension Array where Element==CGPoint {

    /// 星型正多角形の頂点
    ///
    /// - Parameters:
    ///   - frame: 外接する矩形
    ///   - padding: パディング
    ///   - roundness: 外接円と内接円の比(min:0, max:100)
    ///   - vertexes: 外接正多角形の頂点の数
    static func starVertexes(in frame: CGRect, padding: CGFloat, roundness: CGFloat, numberOfVertexes vertexes: Int) -> [CGPoint] {
        return starVertexes(
            radius: Swift.min(frame.width-padding, frame.height-padding)/2,
            center: CGPoint(x: frame.midX, y: frame.midY),
            roundness: Swift.max(Swift.min(roundness, 100), 0)/100,
            numberOfVertexes: Swift.max(vertexes, 2))
    }

    /// 星型正多角形の頂点
    ///
    /// - Parameters:
    ///   - radius: 外接円の半径
    ///   - center: 中心の座標
    ///   - roundness: 外接円と内接円の比(min:0, max:1)
    ///   - vertexes: 外接正多角形の頂点の数
    static func starVertexes(radius: CGFloat, center: CGPoint, roundness: CGFloat, numberOfVertexes vertexes: Int) -> [CGPoint] {
        let vertexes = (vertexes * 2)
        return [Int](0...vertexes).map { offset in
            let r = (offset % 2 == 0) ? radius : roundness * radius 
            let θ = CGFloat(offset)/CGFloat(vertexes) * (2 * CGFloat.pi) - CGFloat.pi/2
            return CGPoint(x: r * cos(θ) + center.x, y: r * sin(θ) + center.y)
        }
    }
}

// MARK: - UIBezierPath
private extension UIBezierPath {

    /// 頂点から頂点へ直線を引き、閉じたパスを作る
    ///
    /// - Parameter vertexes: 頂点
    static func polygon(_ vertexes: [CGPoint]) -> UIBezierPath {
        let path = UIBezierPath()
        /// 最初の頂点へ移動
        path.move(to: vertexes.first!)
        /// 頂点から頂点へ線を引く
        vertexes.forEach { path.addLine(to: $0) }
        /// 曲線を閉じる
        path.close()

        return path
    }

    @discardableResult
    func configure(lineWidth width: CGFloat,
                   capStyle: CGLineCap = .butt,
                   joinStyle: CGLineJoin = .miter) -> UIBezierPath {
        lineWidth = width
        lineCapStyle = capStyle
        lineJoinStyle = joinStyle
        return self
    }
}

できあがりです。

@IBDesignable@IBInspectable を付けたのは、
実装した内容をインタフェースビルダーを使って確認したかったからです。

Custom Class に StarView を設定することで、
こんな感じで表示することができます。

ib_star_view.gif

おわりに

多角形をビューにドローするために使った UIBezierPath の API はとてもシンプルでした。

画像ではなくコードで作成したことで、柔軟な表示ができそうなことを
インタフェースビルダーを使って確認できました。

直線を引くための座標の取得が泥臭くなりがちなので、draw(_ rect: CGRect) の外で
取得する仕組みを作るなどして、ソースコードをシンプルに保ちたいですね。