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

2018.07.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

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

前回の「UIBezierPathを使って遊んでみる(その1)」の続きになります。

ぜひそちらも確認くださいませ。

塗りつぶし

パスが閉じられている状態であれば、その領域内は塗りつぶすことも容易です。

// before
UIColor.red.setStroke()
path.stroke()

// after
UIColor.red.setFill()
path.fill()

このように stroke としていた箇所を fill に変えるだけです。

せっかくなので、星っぽく黄色に塗りつぶしてみます。

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.yellow だと明るすぎるので #FFC84E
    UIColor(red: 1.0, green: 0.784, blue: 0.306, alpha: 1.0).setFill()
    path.fill()
}

IBで見てみると、

このような結果です。

星を並べる

ここまでキレイに星が描けると、実用的なものにしたくなってきます。 星といえば、よくあるのが「評価」の星ですよね。

たとえば、5段階評価をするのであれば、星を5つ並べたいところです。

なので、まずはどう並べれば星が綺麗に並ぶのかを確認してみます。

領域だけの関係性だけを見れば上図のとおりです。

黒枠がビュー全体として、緑の矩形が星型の領域です。 星型は正方形であり、お互いの星は等間隔で横に配置され、 左端の星は全体領域の左端に、右端の星は全体領域の右端に接するというのが前提条件とします。

また、上図では星領域の上下端は全体領域に接する形になっていますが、 全体領域の高さが大きくなった場合には追随せずに 垂直方向に対して星は真ん中に配置されるという前提も条件に足します。

これらの条件を満たすための領域計算をしていきます。

星領域正方形の一辺の長さを計算する

private func rectsOfStars(in rect: CGRect) -> [CGRect] {
    return []
}

こういった計算用メソッドを用意しました。 引数は全体領域です。そこから各星の領域の矩形領域を計算していきます。

まずは星領域の一辺の長さが必要です。

let perWidth = rect.width / 5

星領域は正方形なので、高さも perWidth と等しくなるはずですが、 もしかすると、細長いビューであれば perWidth は全体領域の高さよりも大きい値かもしれません。

この場合、一辺の長さは全体領域の高さに合わせないとハミ出してしまいます。

var perWidth = rect.width / 5 // varに変更

if perWidth < rect.height {
    perWidth = rect.height
}

これにより、星領域の正方形の一辺の長さが確定しました。

星領域同士の間隔

次に、等間隔で並べるために、お互いどれくらいの距離をとるべきかを計算します。

先ほど全体領域を5で割ったので、答えは0になるはずですが、 先のソースコードでif文に入った場合は perWidth の値が変化しているので、それにはあたりません。

というわけで、下の計算で間隔を算出します。

var perWidth = rect.width / 5

if perWidth > rect.height {
    perWidth = rect.height
}

let interval = (rect.width - (perWidth * 5)) / (5 - 1)

変数 interval は最初の図にあった赤い線に当たる部分です。 5つ星を並べるのですから、間隔の数は4です。 全体の幅から星5つ分の幅を引いた余りを間隔の数で割ることで等間隔の長さを算出できます。

垂直に中央である位置

「垂直方向に対して星は真ん中に配置される」という条件を満たすための計算もします。

こちらは簡単で、全体の高さから星領域の高さを引いた値を1/2にすることで求めることができます。

【考え方】

var perWidth = rect.width / 5

if perWidth > rect.height {
    perWidth = rect.height
}

let interval = (rect.width - (perWidth * 5)) / (5 - 1)
let y = (rect.height - perWidth) / 2

各領域の計算

ここまで計算してきた値を元に5つの矩形座標を計算して返します。

return (0..<5).map { i -> CGRect in
    let size = CGSize.zero
    let origin = CGPoint.zero
    return CGRect(origin: origin, size: size)
}

まずはサイズ。正方形の一辺である perWidth によって作れます。

let size = CGSize(width: perWidth, height: perWidth)

次は位置です。 Y位置は、先ほど求めたとおりで、X位置のみループによる計算が必要です。

let origin = CGPoint(x: (perWidth + interval) * CGFloat(i), y: y)

これらを合わせるとこうなります。

    private func rectsOfStars(in rect: CGRect) -> [CGRect] {
        var perWidth = rect.width / 5

        if perWidth > rect.height {
            perWidth = rect.height
        }

        let interval = (rect.width - (perWidth * 5)) / (5 - 1)
        let y = (rect.height - perWidth) / 2
        
        return (0..<5).map { i -> CGRect in
            let size = CGSize(width: perWidth, height: perWidth)
            let origin = CGPoint(x: (perWidth + interval) * CGFloat(i), y: y)
            return CGRect(origin: origin, size: size)
        }
    }

では一旦、どのように領域が取得できたかを確認します。

@IBDesignable class CustomView: UIView {
    
    override func draw(_ rect: CGRect) {
        let colors: [UIColor] = [.red, .blue, .green, .yellow, .gray]
        
        rectsOfStars(in: rect).enumerated().forEach { i, rect in
            let path = UIBezierPath()
            
            path.move(to: rect.origin)
            path.addLines(to: [
                (rect.minX, rect.maxY),
                (rect.maxX, rect.maxY),
                (rect.maxX, rect.minY),
                ])
            path.close()
            
            colors[i].setFill()
            path.fill()
        }
    }
    
    (中略)
}

取得できた矩形領域を「赤、青、緑、黄色、灰色」で順番に塗りつぶしてみました。

IBで確認すると

星を描画する

それでは場所も決まったことですので、星を描画したいと思いますが、 まずはそのための準備として、先ほど作った UIBezierPath への Extension を少しリファクトします。

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

先ほどは、第二引数は drawSize としてCGSizeを渡していましたが、 drawRect としてCGRectを渡すようにしました。

座標の計算時に、CGRectのorigin情報を足すという改修になります。

これで、ビューの実装は以下のようにします。

@IBDesignable class CustomView: UIView {
    
    override func draw(_ rect: CGRect) {
        rectsOfStars(in: rect).enumerated().forEach { i, rectOfStar in
            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: rectOfStar)
            
            UIColor(red: 1.0, green: 0.784, blue: 0.306, alpha: 1.0).setFill()
            path.fill()
        }
    }
    
    (中略)
}

確認してみると

星が美しく並びました!

まとめ

ここまで UIBezierPath を使って、色で塗りつぶした星型を連続でキレイに並べて描くところまでをやってみました。 もう少し続きがありますのでよろしくおねがいします。