[Swift] iOSアプリでドーナツグラフ(Pie Chart)が簡単に描けるビュークラスを日曜大工してみた

コピペで使えるよっ
2020.08.24

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

はじめに

まいどです。CX事業本部の中安です。まいどです。

本日はタイトル通り、ドーナツグラフ(円グラフ)を描画するビュークラスを作ってみたというお話です。

以前にお仕事で作っていた同様のものを作ったのですが、再構築する形で汎用的なビュークラスにしてみました。 せっかく作ったのでブログにしたためてみようかと思った次第です。

ちなみに完成イメージはこんな感じ。

この手のものはライブラリなどで既に何個もありそうですが、自分の手で作ってみるのも楽しいものです。

基本的な機能しか備わっていませんが、同じようなUIを検討している開発者の方のヒントになれば幸いです。

クラスのインターフェイス

まずはクラスのインターフェイスのご紹介

import UIKit

@IBDesignable class PieChartView : UIView {

    @IBInspectable var maxValue: CGFloat { get set }

    @IBInspectable var value: CGFloat { get set }

    @IBInspectable var barWidth: CGFloat { get set }

    @IBInspectable var barPadding: CGFloat { get set }

    @IBInspectable var chartBackgroundColor: UIColor { get set }

    @IBInspectable var chartTintColor: UIColor { get set }

    @IBInspectable var animationDuration: Double { get set }

    func set(value: CGFloat, maxValue: CGFloat, animated: Bool = true)
}

クラスの名前は PieChartView としました。

プロパティ

maxValue

グラフの最大値です。デフォルト値は100にしています。

value

グラフの値です。 valuemaxValueと同じ値(またはそれ以上)であればグラフはすべて塗りつぶされます。 デフォルト値は0にしています。

barWidth

グラフの太さです。デフォルト値は20にしています。

barPadding

グラフの塗りつぶし部分のパディング値です。 パディングをつけることで下図のように塗りつぶしに余白をつけることができます。

デフォルト値は0にしています。

chartBackgroundColor

グラフの塗りつぶされていない背景部分(トラック部分)の色です。デフォルト値は.secondarySystemBackgroundにしています。

chartTintColor

グラフの塗りつぶし部分の色です。デフォルト値は.orangeにしています。

animationDuration

アニメーションにかける時間(秒数、小数可)です。デフォルト値は0.8(秒)にしています。

メソッド

set(value:maxValue:animated:)

グラフに対して値を指定して描画させます。

  • value: グラフの値。グラフの分子にあたる値
  • maxValue: グラフの最大値。グラフの分母にあたる値
  • animated: 描画がアニメーションするかどうか。省略可。デフォルトはアニメーション有り

ソースコードとポイント

ではザザッとこのクラスのソースコードを載せてしまいます。おそらくコピペしても動くとは思います。

いくつかのポイントは下の方に書いておきます。

import UIKit

@IBDesignable class PieChartView : UIView {
    
    // MARK: Public Inspectable Properties
    
    @IBInspectable var maxValue: CGFloat = 100 {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var value: CGFloat = 0 {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var barWidth: CGFloat = 20 {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var barPadding: CGFloat = 0 {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var chartBackgroundColor: UIColor = .secondarySystemBackground {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var chartTintColor: UIColor = .orange {
        didSet {
            setup()
        }
    }
    
    @IBInspectable var animationDuration: Double = 0.8
     
    // MARK: Public Functions
    
    func set(value: CGFloat, maxValue: CGFloat, animated: Bool = true) {
        set(rect: centerSquareRect(bounds), value: value, maxValue: maxValue, animated: animated)
    }
    
    // MARK: Draw
    
    override func draw(_ rect: CGRect) {
        let pieChartRect = centerSquareRect(rect)
        setChartBackground(rect: pieChartRect)
        set(rect: pieChartRect, value: value, maxValue: maxValue, animated: false)
    }
    
    // MARK: Private Properties
    
    private var tintShapeLayer: CAShapeLayer?
    
    private let pi = CGFloat(Double.pi)
    
    // MARK: Private Functions
    
    private func set(rect: CGRect, value: CGFloat, maxValue: CGFloat, animated: Bool = true) {
        tintShapeLayer?.removeFromSuperlayer()
        tintShapeLayer = createTintShapeLayer(rect: rect, value: value, maxValue: maxValue)
        layer.addSublayer(tintShapeLayer!)
        
        if animated {
            let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))
            animation.duration = CFTimeInterval(animationDuration)
            animation.fromValue = 0.0
            animation.toValue = 1.0
            animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            tintShapeLayer!.add(animation, forKey: "pieChartAnimation")
        }
    }
        
    private func setup() {
        set(value: value, maxValue: maxValue, animated: false)
    }
    
    private func pieChartPath(rect: CGRect, startAngle: CGFloat, endAngle: CGFloat) -> UIBezierPath {
        return UIBezierPath(
            arcCenter: centerPoint(of: rect),
            radius: (max(rect.width, rect.height) / 2) - (barWidth / 2),
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: true
        )
    }
    
    private func setChartBackground(rect: CGRect) {
        let path = pieChartPath(
            rect: rect,
            startAngle: 0,
            endAngle: pi * 2
        )
        path.lineWidth = barWidth
        chartBackgroundColor.setStroke()
        path.stroke()
    }
    
    private func createTintShapeLayer(rect: CGRect, value: CGFloat, maxValue: CGFloat) -> CAShapeLayer {
        let shapeLayer = CAShapeLayer()
        shapeLayer.frame = rect
        shapeLayer.lineWidth = barWidth - (barPadding * 2)
        shapeLayer.strokeColor = chartTintColor.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        
        let percentage = calculatePercentage(numerator: value, denominator: maxValue)
        let start = 3 * pi / 2
        let end = start + (percentage * 2 * pi)
        
        shapeLayer.path = pieChartPath(
            rect: centerSquareRect(rect),
            startAngle: start,
            endAngle: end
        ).cgPath
        
        return shapeLayer
    }
    
    private func centerSquareRect(_ rect: CGRect) -> CGRect {
        let sideWidth = min(rect.width, rect.height)
        return CGRect(
            x: (rect.width - sideWidth) / 2,
            y: (rect.height - sideWidth) / 2,
            width: sideWidth,
            height: sideWidth
        )
    }
    
    private func centerPoint(of rect: CGRect) -> CGPoint {
        return CGPoint(
            x: rect.minX + (rect.width / 2),
            y: rect.minY + (rect.height / 2)
        )
    }
    
    private func calculatePercentage(numerator: CGFloat, denominator: CGFloat) -> CGFloat {
        if numerator <= 0 || denominator <= 0 {
            return 0.0
        } else if numerator >= denominator {
            return 1.0
        } else {
            return numerator / denominator
        }
    }
}

IBInspectableプロパティとその反映

IBInspectableなプロパティの値がインスペクタでセットされたときには、 それがIB上で反映されるようになっていると嬉しいので、以下のように setup()メソッド経由で set()メソッドを呼び出しています。

    @IBInspectable var chartTintColor: UIColor = .orange {
        didSet {
            setup()
        }
    }
    private func setup() {
        set(value: value, maxValue: maxValue, animated: false)
    }

これはこのクラスに関わらず、自作のビューコンポーネントを作るときにも使える手だと思います。 ただし、複雑な描画処理をしてしまったり、たくさん配置してしまうと IB自体の描画が遅くなるので注意しましょう。

ビュー中央に真円を描く

このクラスでは配置された縦横の比率問わず、ビューの真ん中に真円を描く形になるように作っています。

横に長い領域(Rect)が取られた時は、全体幅の中央。

縦に長い領域(Rect)の時は、全体の高さの中央に配置されます。

※ ピンクが全体領域。緑が中央を表す正方形。

その計算はこのプライベートメソッドが担っています。

    private func centerSquareRect(_ rect: CGRect) -> CGRect {
        let sideWidth = min(rect.width, rect.height)
        return CGRect(
            x: (rect.width - sideWidth) / 2,
            y: (rect.height - sideWidth) / 2,
            width: sideWidth,
            height: sideWidth
        )
    }

ただ、実際に使う時は1:1の正方形で配置するのが分かりやすいと思います。

ベジェ曲線で円を描く

背景もグラフの色もベジェ曲線でパスを描くことで円を描画しています。 その共通処理として下記のプライベートメソッドを用意しています。

    private func pieChartPath(rect: CGRect, startAngle: CGFloat, endAngle: CGFloat) -> UIBezierPath {
        return UIBezierPath(
            arcCenter: centerPoint(of: rect),
            radius: (max(rect.width, rect.height) / 2) - (barWidth / 2),
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: true
        )
    }

このメソッドがやっていることは、コンパスのように中心点を置き、そこからぐるっと指定した分だけパスを描いているのですが、 この startAngleendAngle に何を渡せばいいのかがミソになってくると思います。

自分は文系プログラマーなので細かくは上手く説明できませんが、 下図の角度と弧度法の対応図を見ていくと、渡すべき値が見えてきます。

円の右を 0 として基準となり、360°周回すると 2π となるということがわかります。

参考: ラジアン - Wikipedia

下記は背景部分を描画しているソースコードですが

    private func setChartBackground(rect: CGRect) {
        let path = pieChartPath(
            rect: rect,
            startAngle: 0,
            endAngle: pi * 2
        )
        path.lineWidth = barWidth
        chartBackgroundColor.setStroke()
        path.stroke()
    }

startAngleには0を、endAngleにはpi * 2 (すなわち2π)を渡しているということは、 円の右から一周して円の右までパスを描いていることになります。

では、グラフの塗りつぶしの方はというと、下記のメソッドで定義していますが

    private func createTintShapeLayer(rect: CGRect, value: CGFloat, maxValue: CGFloat) -> CAShapeLayer {

        // 略
        
        let percentage = calculatePercentage(numerator: value, denominator: maxValue)
        let start = 3 * pi / 2
        let end = start + (percentage * 2 * pi)
        
        shapeLayer.path = pieChartPath(
            rect: centerSquareRect(rect),
            startAngle: start,
            endAngle: end
        ).cgPath
        
        return shapeLayer
    }

円の上は角度でいうと270°であり、その位置は 2分の3π になる。そしてその位置から1周の 2π を100%としたうちの、必要分だけ描画するという流れになります。

背景と塗りつぶしの描画の差

背景はベジェ曲線のパス(UIBezierPath)を作って、そのままその描画メソッドを呼び出して描画しています。

    private func setChartBackground(rect: CGRect) {
        let path = pieChartPath(
            rect: rect,
            startAngle: 0,
            endAngle: pi * 2
        )
        path.lineWidth = barWidth
        chartBackgroundColor.setStroke()
        path.stroke()
    }

しかし、塗りつぶしの方はアニメーションをさせる必要があるため、CAShapeLayerを使っての描画となっています。

    private func set(rect: CGRect, value: CGFloat, maxValue: CGFloat, animated: Bool = true) {
        tintShapeLayer?.removeFromSuperlayer()
        tintShapeLayer = createTintShapeLayer(rect: rect, value: value, maxValue: maxValue)
        layer.addSublayer(tintShapeLayer!)
        
        if animated {
            let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.strokeEnd))
            animation.duration = CFTimeInterval(animationDuration)
            animation.fromValue = 0.0
            animation.toValue = 1.0
            animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            tintShapeLayer!.add(animation, forKey: "pieChartAnimation")
        }
    }

このレイヤーを使っての描画については、Developers.IOでも過去にブログがありますので、そちらを参考にしてみてください。原理はこの記事と同じです。

[iOS] カウントダウンビューを作ってみました | Developers.IO

映画のフィルムなどで、始まる前にカウントダウンするあのイメージ。(伝わらない?) あれを表示したかったのですが、UIKitのアニメーションでは、ちょっと無理のようだったので、 UIBezierPathと CABasicAnimation で表現しました。 本記事は、その作業の纏めです。 最初に、作ってみたカウントダウンです。 UIBezierPath は、直線や曲線などを描画できる、描画用のクラスです。 今回は、これを使用して円を描画しました。

最後に

このドーナツグラフのクラスは、用途としてはパーセンテージ(進捗)の表示をするために使うことが多そうです。 しかし、工夫によってはいくつかの要素の割合を示したりする本来の円グラフなども作れそうです。

またスキマ時間にそういったものを作る機会があればブログにすることにします。

では、またー。