[Swift 4] UIBezierPathを使って遊んでみる(その1)

はじめに

モバイルアプリサービス部の中安です。

UIBezierPath は CoreGraphic において「ベジエ曲線」を描くためのクラスです。

さまざまなUIはサードパーティなライブラリを用いれば簡単に実現できますが、 自分で地道に描画を作ってみるのも楽しいなと思い、色々と遊んでみました。

iOS初心者の方にも分かりやすくなるように細かめに書いてみましたので 何かの参考になれば幸いです。

準備

まず最初に、カスタムなビュークラスとして 「CustomView」 というクラスを用意しました。 IBDesignable を付けて、Interface Builder (以下、IB) でサクッと描画結果を確認できるようにしておきます。

// CustomView.swift

@IBDesignable class CustomView: UIView {
    
    override func draw(_ rect: CGRect) {
        // 中身はこれから
    }
}

ストーリーボードは、下図のように CustomView を真ん中にドドンと貼り付けてるだけです。 領域がわかりやすいように全体のビューには青っぽい色を付けました。

Main.storyboard 2017-11-15 18-49-18

一本の直線を描く

はじめは直線を描くところから。

@IBDesignable class CustomView: UIView {
    
    override func draw(_ rect: CGRect) {
        
        let path = UIBezierPath()
        
        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
        path.close()
        
        UIColor.red.setStroke()
        path.stroke()
    }
}

UIBezierPath オブジェクトを作って、パスを指定して、最後にパスを閉じて、線の色を決めて、線を描画してもらう・・・という流れです。

CGPoint の部分を見てもらえれば、rect 領域の水平方向の中央の上から下まで線を引くというものになっていると思います。

ここまでできたら IB で見てみます。

Main.storyboard 2017-11-15 19-02-24

はい、思った通りに線が引けました。

次に何本かの線を引いていこうと思いますが、

path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))

が長ったらしいので、もっと楽に書けるように extension しちゃいます。

// CustomView.swift内

private extension UIBezierPath {
    
    func move(to x: CGFloat, _ y: CGFloat) {
        move(to: CGPoint(x: x, y: y))
    }
    
    func addLine(to x: CGFloat, _ y: CGFloat) {
        addLine(to: CGPoint(x: x, y: y))
    }
}

これにより、CGPoint の記述を削れます。

path.move(to: rect.midX, rect.minY)
path.addLine(to: rect.midX, rect.maxY)

短くなりました。(個人の好みですが・・・)

同一ファイル内で private extension にすることで、このファイルだけで使える拡張機能という風にできます。

複数の直線を描く

先ほどはただの直線でしたが、今度は複数の線を引いて、菱形を作ります。

    override func draw(_ rect: CGRect) {
        
        let path = UIBezierPath()

        path.move(to: rect.midX, rect.minY)
        path.addLine(to: rect.minX, rect.midY)
        path.addLine(to: rect.midX, rect.maxY)
        path.addLine(to: rect.maxX, rect.midY)
        path.close()
        
        UIColor.red.setStroke()
        path.stroke()
    }

先ほどと開始位置は同じですが、今度は左下→右下→右上とパスに線を追加していきます。 菱形を完成させるには、最後に左上にも線を追加しなければならないように見えますが、 パスを閉じることでそれは完結するので処理は入れません。

つまり、path.close() の前に path.addLine(to: rect.midX, rect.minY) は不要ということです。

結果をIBで見てみます。

問題なく描けました。

さて、ここでももう少しシンプルになるように extension してしまいます。

private extension UIBezierPath {
    
    (中略)
    
    func addLines(to positions: [(CGFloat, CGFloat)]) {
        positions.forEach { position in
            addLine(to: position.0, position.1)
        }
    }
}

複数の直線をひとつのメソッドで書けるようにしました。

path.move(to: rect.midX, rect.minY)
path.addLines(to: [
    (rect.minX, rect.midY),
    (rect.midX, rect.maxY),
    (rect.maxX, rect.midY),
])
path.close()

複雑な直線を描く

次は少し複雑な直線を描くということで「星型」のパスを書いてみることにします。

この場合、直線を組み合わせて描いていくことになりますが、そのためには星の各頂点の位置を把握する必要があると思います。

いろいろな方法が考えられると思いますが、今回は次のようなやり方で把握してみます。

  • 星の形の画像を用意する
  • 100 x 100のサイズにリサイズする
  • 0,0 からの各頂点位置を測る

Mac標準のプレビューアプリでもこれくらいならすぐできます。

星の一番上の頂点から左回りに頂点を取っていくと、

50, 2 → 一番上
35, 34
0, 40
25, 63
21, 96
50, 81 → 真ん中

ここまでで左側半分です。対称の形なので残りの右側はXの位置を100から各値を計算すればOKですね。 それを加えて、先ほどまでのソースにこの座標を入れてみます。

override func draw(_ rect: CGRect) {
    
    let path = UIBezierPath()
    
    path.move(to: 50, 2) // 一番上
    path.addLines(to: [
        (35, 34), // ここから右側
        (0, 40),
        (25, 63),
        (21, 96),
        (50, 81), // 真ん中
        (79, 96), // ここから右側
        (75, 63),
        (100, 40),
        (65, 34),
    ])
    path.close()
    
    UIColor.red.setStroke()
    path.stroke()
}

IBで確認してみます。

キレイな星型にできました。

ただし、これは完全な固定値ですので、100 x 100 の星にしかなりません。

今回は、ビューの領域いっぱいの星を描画したいところなので、与えた値は「固定の座標位置ではなくパーセンテージである」という解釈に変えたいと思います。

そのために、このようなメソッドを用意してみました。

private extension UIBezierPath {
    
    func setLines(to percentages: [(CGFloat, CGFloat)], in drawSize: CGSize) {
        percentages.forEach { percentage in
            if percentages.first! == percentage {
                move(to: point(of: percentage, in: drawSize))
            } else {
                addLine(to: point(of: percentage, in: drawSize))
            }
        }
        close()
    }
    
    private func point(of percentage: (CGFloat, CGFloat), in drawSize: CGSize) -> CGPoint {
        return CGPoint(
            x: percentage.0 / 100 * drawSize.width,
            y: percentage.1 / 100 * drawSize.height
        )
    }
}

与えた座標の引数を「描画領域(drawSize)の左上からのパーセンテージ」として扱うようにしています。

100 x 100 にリサイズして座標を測っていったのは、このためということです。

先ほどまでは「パスの最初に移動」「パスに線を加える」「パスを閉じる」を別々にしていましたが、 setLines(to:in:) メソッドでは、ひとつの紋様を書き切るための処理ということで、ひとまとめにしています。

さぁ、これを実際に使ってみます。

@IBDesignable class CustomView: UIView {
    
    override func draw(_ rect: CGRect) {
        let path = UIBezierPath()
        
        path.setLines(to: [
            (50, 2), // 一番上
            (35, 34), // ここから右側
            (0, 40),
            (25, 63),
            (21, 96),
            (50, 81), // 真ん中
            (79, 96), // ここから右側
            (75, 63),
            (100, 40),
            (65, 34),
            ], in: rect.size)
        
        UIColor.red.setStroke()
        path.stroke()
    }
}

領域いっぱいに星が描けました。

領域のパーセンテージで計算しているので、ビューのサイズが変わってもそれに追随してくれます。 試しに色んなサイズに変更してみたのが下の図です。

上手くいっているようです。

まとめ

ここまで UIBezierPath を使って、親ビューのサイズに追随した星型を描くところまでをやっみました。 まだ色々と続きがありますのでよろしくおねがいします。