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

2018.07.23

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

はじめに

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

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

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

調整可能にする

さて、ここまでですごく「っぽく」なった感じですが、星の数は5つで固定ですし、星の色も黄色で固定です。 ここまで来るともう少し使用時に簡単にカスタマイズさせたい・・・という欲が出てきます。

星の色を指定可能にする

IBInspectable の仕組みを使って、星の色を指定可能にします。

@IBDesignable class CustomView: UIView {
    
    @IBInspectable var rateColor: UIColor = UIColor(red: 1.0, green: 0.784, blue: 0.306, alpha: 1.0)
    
    override func draw(_ rect: CGRect) {
        
        (中略)
        
        rateColor.setFill()
        path.fill()
    }
}

先ほどまで直に書いていた星の色を @IBInspectable 付きのプロパティ化にしました。

こうすることで、IB上から

このように星の色を指定できるようになりました。簡単!

星の数を指定可能にする

次は5で固定されていた星の数の調整です。 星の数を表すプロパティを足して、先ほどのように"5"と指定した箇所を書き換えていきます。

@IBInspectable var rateCount: Int = 5

ここで気をつけなければならないのは、プロパティがIntで確定してしまったので、 先ほどまで型推論で進んでいた箇所はキャストをしてやる必要が出てきます。

箇所が多いので下記のようにラッピングしてやるといいかもしれません。

@IBDesignable class CustomView: UIView {
    
    @IBInspectable var rateCount: Int = 5
    
    (中略)
    
    private func rectsOfStars(in rect: CGRect) -> [CGRect] {
        var perWidth = rect.width / rateCountFloat

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

        let interval = (rect.width - (perWidth * rateCountFloat)) / (rateCountFloat - 1)
        let y = (rect.height - perWidth) / 2
        
        return (0..<rateCount).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)
        }
    }
    
    private var rateCountFloat: CGFloat {
        return CGFloat(rateCount)
    }
}

IBで確認します。

星が3つになりました!

しかし、これにはまだ問題があります。 星の数を1にすると、残念なことに描画されなくなってしまいます。

問題なのは interval を求める

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

こちらの箇所で、「ゼロ割り」をしてしまうことになるからです。

当然、1よりも少ない数であっても不具合が起きる可能性をはらんでいます。 なので、ここの対策を入れる必要があります。

private func rectsOfStars(in rect: CGRect) -> [CGRect] {
    if rateCount <= 0 { // 対策(1)
        return []
    }
    
    var perWidth = rect.width / rateCountFloat

    if perWidth > rect.height {
        perWidth = rect.height
    }
    
    var interval: CGFloat = 0 // 対策(2)
    if rateCount > 1 {
        interval = (rect.width - (perWidth * rateCountFloat)) / (rateCountFloat - 1)
    }
    let y = (rect.height - perWidth) / 2
    
    return (0..<rateCount).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)
    }
}

上記のように2箇所の対策を講じることで、IB上で rateCount を 1 にしても星が表示されるようになったと思います。

星同士の間隔をあける

今までの実装で、星同士は全体領域を均一に水平に分割した長さが、高さを超えない限り、近接し合うようにしてあります。 星同士の間隔はもう少し距離があってもいいのではないかとも思えてきます。 そのへんは好みの問題ですので、使用者が調整可能にしておけば便利なのではないかと思います。

またひとつプロパティを足してみます。

@IBDesignable class CustomView: UIView {
    
    (中略)
        
    @IBInspectable var rateMinimumInterval: CGFloat = 0
    
    (中略)
}

星同士の間隔は最低でもこの値以上開くという px数を指定できるようにします。

さて、今の実装にどう組み込むかという方法ですが、 現在のところ「正方形一辺の幅」は「全体領域の幅を個数で割ったもの」になっています。 そして、「星同士の間隔」はその逆算で算出されているというのが今までの実装です。

ということは、「全体領域の幅」から「星同士の間隔の合計」を引いた値を「個数」で割れば、 おのずと逆算時に算出できるはずです。

ソースを見たほうが早いかもしれないので、載せます。

private func rectsOfStars(in rect: CGRect) -> [CGRect] {
    if rateCount <= 0 {
        return []
    }
    
    // (1) 星同士の間隔の合計
    let sumInterval = rateMinimumInterval * (rateCountFloat - 1)
    // (2) 全体から(1)を引いて個数で割る
    var perWidth = (rect.width - sumInterval) / rateCountFloat
    
    if perWidth > rect.height {
        perWidth = rect.height
    }
    
    var interval: CGFloat = 0
    if rateCount > 1 {
        // (3) 逆算する時に rateMinimumInterval 以上の値になる
        interval = (rect.width - (perWidth * rateCountFloat)) / (rateCountFloat - 1)
    }
    let y = (rect.height - perWidth) / 2
    
    return (0..<rateCount).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)
    }
}

IBで確認してみます。

このように星同士が 10px の間隔があきました。

コンテキストを使用する

ここまでは UIBezierPath を使用して描画を行ってきました。 もう少し複雑な処理を入れていくためには、描画コンテキストとパスを組み合わせていく手法の方がベターかもしれません。

次のステップの前に一度ソースを整理します。

描画とパスの分離

まずは、「描画」と「パス書き」は処理として分けたいので、 UIBezierPath の作成は draw から分離させます。

@IBDesignable class CustomView: UIView {
    
    (中略)
    
    override func draw(_ rect: CGRect) {
        rectsOfStars(in: rect).enumerated().forEach { i, rectOfStar in
            let path = bezierPathOfStar(rectOfStar)
            rateColor.setFill()
            path.fill()
        }
    }
    
    private func bezierPathOfStar(_ rect: CGRect) -> UIBezierPath {
        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)
        return path
    }
    
    (中略)
}

責務の交代

次に、描画自体の責務をパスから描画コンテキストに入れ替えます。

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    rectsOfStars(in: rect).enumerated().forEach { i, rectOfStar in
        context.addPath(bezierPathOfStar(rectOfStar).cgPath)
        context.setFillColor(rateColor.cgColor)
        context.fillPath()
    }
    UIGraphicsEndImageContext()
}

ソース自体は冗長になりましたが、 パス自身が描画をするのではなく、描画コンテキストがパスを使って描画するという仕組みに入れ替わりました。

IBで確認しても、今までと見た目は何も変わらないことが確認できると思います。

グラデーションさせる

単色塗りつぶしの星たちに対して、グラデーションで装飾させてやりたくなりました。

そのためにいくつかの下準備をしたいと思います。

プロパティの変更と追加

とりあえず2色のグラデーションをさせてあげたいので、設定できる色情報を増やします。

rateColor を削除して、ふたつのプロパティを定義します。 ちなみに IB で既に rateColor を指定している場合、この変更をする前に消しておかないとゴミが溜まってしまうので注意です。

@IBInspectable var rateFirstColor: UIColor = UIColor(red: 1.0, green: 0.784, blue: 0.306, alpha: 1.0)
@IBInspectable var rateSecondColor: UIColor = UIColor(red: 1.0, green: 0.925, blue: 0.349, alpha: 1.0)

実装上は、rateColor が残っているのでコンパイルエラーになると思います。 その箇所を一旦 rateFirstColor に置き換えて怒りを沈めてあげましょう。

CGRect の extension

後ほどグラデーションをさせる時に、グラデーション方向を定義してやる必要があります。 指定ミス回避と可読性のために CGRect に 9種類のプロパティを足してやることにします。

private extension CGRect {
    
    var leftTop: CGPoint {
        return CGPoint(x: minX, y: minY)
    }
    
    var centerTop: CGPoint {
        return CGPoint(x: midX, y: minY)
    }
    
    var rightTop: CGPoint {
        return CGPoint(x: maxX, y: minY)
    }
    
    var leftCenter: CGPoint {
        return CGPoint(x: minX, y: midY)
    }
    
    var centerCenter: CGPoint {
        return CGPoint(x: midX, y: midY)
    }
    
    var rightCenter: CGPoint {
        return CGPoint(x: maxX, y: midY)
    }
    
    var leftBottom: CGPoint {
        return CGPoint(x: minX, y: maxY)
    }
    
    var centerBottom: CGPoint {
        return CGPoint(x: midX, y: maxY)
    }
    
    var rightBottom: CGPoint {
        return CGPoint(x: maxX, y: maxY)
    }
}

定義したのは名前の通りですが、下図の各位置を取得できるようにしました。

グラデーションオブジェクトの作成

描画コンテキストにグラデーションをさせるためには、CGGradientというオブジェクトを使う必要があります。

まずはその作成から実装します。

@IBDesignable class CustomView: UIView {
    
    (中略)
    
    private var gradient: CGGradient {
        let colorsSpace = CGColorSpaceCreateDeviceRGB()
        let colors = [rateFirstColor.cgColor, rateSecondColor.cgColor] as CFArray
        let locations = [0.0, 1.0] as [CGFloat]
        return CGGradient(colorsSpace: colorsSpace, colors: colors, locations: locations)!
    }
}

色空間と色などを渡してグラデーションオブジェクトを作成します。

このうちの、locations はグラデーション色を切り替える位置の情報なのですが、 こちらはおそらく文章の説明よりも、グラデーションが実装できた後に数値を入れ替えてみたほうが効果がわかるのではないでしょうか。

グラデーションを適用する

いよいよ、星にグラデーションを付けていきます。 ただし、単純にグラデーションを付けると言っても、やることは結構ややこしく、以下の手順を踏んでやる必要があります。

  1. 描画コンテキストに星型のパスを追加する
  2. 描画コンテキストにパスの形に切り取らせる(クリップする)
  3. グラデーションの方向を決めてやる
  4. 描画コンテキストにグラデーションの描画を指示する
  5. 描画コンテキストのスタックに描画状態を保存(プッシュ)してやる
  6. 描画状態を元に戻す

これをソースコードに落とし込んでやります。

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    rectsOfStars(in: rect).enumerated().forEach { i, rectOfStar in
        context.saveGState() // 5.
        context.addPath(bezierPathOfStar(rectOfStar).cgPath) // 1.
        context.clip() // 2.
        context.drawLinearGradient( // 4.
            gradient,
            start: rectOfStar.leftTop, // 3.
            end: rectOfStar.rightBottom, // 3.
            options: []
        )
        context.restoreGState() // 6.
    }
    UIGraphicsEndImageContext()
}

グラデーションが上手く付いたことが IB から確認できると思います。

星を増減させる

あたりまえな話ですが、評価は常に満点ではありません。 しかしながら、現在は満点状態の星しか表示することができません。

では、新たにプロパティを追加して評価を設定できるようにしてみたいと思います。

@IBDesignable class CustomView: UIView {
    
    @IBInspectable var rate: CGFloat = 3.5
    
    (中略)
}

rate プロパティは「5段階評価(星5つ)に対して、3.5の評価」という仕様にしようと思います。 すなわち、星は3つと半分だけ描画される状態にしたいということです。

これを実現するにはどうするか。 3.5個分の星のパスをそのように書いていく方法もあるかとおもいますが、 今回は星のパスは5つすべて書いておいて、右1.5個分の星を消してしまう方法を取りたいと思います。

これには「ブレンドモード」という仕組みを使います。その名の通りですが、描画同士の重ね方を指定します。

試しに下記のようなソースを書いてみます。

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    rectsOfStars(in: rect).enumerated().forEach { i, rectOfStar in
        context.saveGState()
        context.addPath(bezierPathOfStar(rectOfStar).cgPath)
        context.clip()
        context.drawLinearGradient(
            gradient,
            start: rectOfStar.leftTop,
            end: rectOfStar.rightBottom,
            options: []
        )
        context.restoreGState()
    }
    
    // 追加ここから
    context.saveGState()
    context.setBlendMode(.destinationOut)
    context.fill(CGRect(x: rect.midX, y: 0, width: rect.midX, height: rect.height))
    context.restoreGState()
    // 追加ここまで
        
    UIGraphicsEndImageContext()
}

さきほどまでの星の描画の下に4行の処理を追加しています。

ここで、setBlendMode() メソッドによってコンテキストにブレンドモードを指定しています。 そして全体領域の右半分全てをfillをしているだけです。

この destinationOut というブレンドモードは、「新しく描画した部分が既存の描画部分と重なる場合、その部分は透過する」というモードで、 もう少し噛み砕くと「重ならないところだけ描画する」というものになります。

これを IB で確認してみると

コレジャナイ感がありますね。 しかし、実際はブレンドモードは適用されており、ビューの背景色の設定とCoreGraphicsの兼ね合いによって黒く表示されてしまっているようです。 なので、まずは IB 上の カスタムビューの背景色を white から clear にしてみます。

すると、こうなりました。 とりあえず半分を透過させることに成功したようです。

まとめ

ここまで UIBezierPath を使って、グラデーション付きの星を任意の星の数だけ表示できるようになりました。

このシリーズ記事は以上になります。

もちろん、実装方法は一例に過ぎません。よりよい方法もあると思いますし、「形は星型じゃないほうがいい」とか「グラデーションは要らない」とか「色の変更はなしでいい」とか「他にもつけたい機能がある」とかあると思いますが、この記事が何かのヒントなどになれば幸いです。

ではでは。