Androidアプリにおける角丸の帯グラフの実装

こんにちは、フロントエンドエンジニアの権守です。Androidアプリ開発を始めてから2か月が経ちましたが、まだまだ実装に苦戦することも多いです。本記事では特に苦戦した実装の1つである角丸の帯グラフについて実装方法を3パターン紹介します。

f:id:vasilyjp:20171109181833j:plain

満たすべき仕様

今回の実装ではAPI level 16以上を対象とし、実装する帯グラフは以下のデザイン要件を満たす必要がありました。

  • 両端が角丸である
  • グラフを構成するデータは二種類
  • 並び順は大きい順ではなく固定
  • 色は過半数かどうかで決まる
f:id:vasilyjp:20171109174650p:plain

(実際に使う場合は割合を表すラベルも併記することになると思いますが、本記事では省略します)

実装方法

本記事で紹介する実装はgithubに上げてありますので、必要に応じて参照してください。

1. ShapeDrawableを使った実装

Androidアプリで角丸を実装することを考えると、まず最初に思いつくのはShapeDrawableでcornersを指定したrectangleを用いる方法でしょう。しかし、この方法では割合が極端な場合に角丸部分をうまく表示できず潰れてしまいます。

f:id:vasilyjp:20171109174708p:plain

そこで、ShapeDrawableのrectangleではなくringを用います。ringは真ん中が透過された円形を描画できるので、それをうまく使い角の部分を背景色で塗りつぶします。 具体的には次のようなDrawableを用意します。

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:innerRadius="15dp"
    android:shape="ring"
    android:thickness="15dp"
    android:useLevel="false">
    <size
        android:width="30dp"
        android:height="30dp" />
    <solid android:color="#FAFAFA" />
</shape>
f:id:vasilyjp:20171109175910p:plain

(Drawableのプレビュー、黒い部分は透過を表します)

このDrawableをClipDrawableを用いて半分に切ることで角の部分だけ塗りつぶすことができます。

この実装方法の問題点は、Drawable内に大きさや背景色を予め指定する必要がある点です。

2. Canvasによる描画を使った実装

Drawableを使うことを諦め、もっと原始的な実装方法を考えると、Canvasを用いてグラフを表す図形を描画する方法もあります。 しかし、Canvasを使った描画も割合が極端な例(2色から構成される角丸の場合)を考慮すると1の実装と同じくロジックは複雑になります。その場合、drawCircleとdrawRectangleを組み合わせるだけでなく、drawArcもうまく組み合わせて使う必要があります。

具体的には、次のようなステップで描画を行います。

  1. 両端に過半数の色で円を描画
  2. 右の長方形を描画
  3. 右の円弧を描画
  4. 左の長方形を描画
  5. 左の円弧を描画

右の要素が占める割合毎のグラフの描画ステップは以下になります。

98% 60% 2%
f:id:vasilyjp:20171109174721p:plain f:id:vasilyjp:20171109174736p:plain f:id:vasilyjp:20171109174953p:plain

実際のコードは次の通りです。

class RoundedBandGraph3 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : View(context, attrs, defStyleAttr) {

    private val paint = Paint().apply { isAntiAlias = true }
    private val leftRect = Rect()
    private val rightRect = Rect()
    private val leftArcArea = RectF()
    private val rightArcArea = RectF()

    var positivePercentage: Int by Delegates.notNull()

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        val radius = height / 2
        val positiveRectangleWidth = width * positivePercentage / 100
        val negativeRectangleWidth = width - positiveRectangleWidth

        // ステップ1
        paint.color = ContextCompat.getColor(context, R.color.major)
        canvas?.drawCircle(radius.toFloat(), radius.toFloat(), radius.toFloat(), paint)
        canvas?.drawCircle((width - radius).toFloat(), radius.toFloat(), radius.toFloat(), paint)

        // ステップ2
        if (positivePercentage >= 50) {
            paint.color = ContextCompat.getColor(context, R.color.major)
        } else {
            paint.color = ContextCompat.getColor(context, R.color.minor)
        }
        if (positiveRectangleWidth > radius) {
            rightRect.set(Math.max(width - positiveRectangleWidth, radius), 0, width - radius, height)
            canvas?.drawRect(rightRect, paint)
        }

        // ステップ3
        var x = Math.max(0, radius - positiveRectangleWidth)
        var angle = Math.toDegrees(Math.acos(x.toDouble() / radius.toDouble())).toFloat()
        rightArcArea.set((width - height).toFloat(), 0f, width.toFloat(), height.toFloat())
        canvas?.drawArc(rightArcArea, 0f - angle, 2f * angle, false, paint)

        // ステップ4
        if (positivePercentage >= 50) {
            paint.color = ContextCompat.getColor(context, R.color.minor)
        } else {
            paint.color = ContextCompat.getColor(context, R.color.major)
        }
        if (negativeRectangleWidth > radius) {
            leftRect.set(radius, 0, Math.min(negativeRectangleWidth, width - radius), height)
            canvas?.drawRect(leftRect, paint)
        }

        // ステップ5
        x = Math.max(0, radius - negativeRectangleWidth)
        angle = Math.toDegrees(Math.acos(x.toDouble() / radius.toDouble())).toFloat()
        leftArcArea.set(0f, 0f, height.toFloat(), height.toFloat())
        canvas?.drawArc(leftArcArea, 180f - angle, 2f * angle, false, paint)
    }
}

このコードの注意すべき点としては、長方形の描画は円弧の領域を除くように大きさを決める点と、円弧の大きさを求める点が挙げられます。 円弧の描画には孤の始点の角度と孤の大きさ(角度)を指定する必要があります。両端の円の半径rから円弧が占める横幅を引いた大きさをxとし、arccos(x/r)を求めることで、始点の角度を求めることができます。弧の大きさ(角度)は求めた角度の2倍になります。

3. CanvasのClipPathを使った実装

CanvasのClipPathを使うと、指定した領域のみ描画するということができます。 しかし、ClipPathはAPI level 18以降でしか動きません。これは、Hardware AccelerationでClipPathをサポートしているのがAPI level 18以降だからです。コード上でバージョンを判定し、明示的にHardware Accelerationを無効にすることで、API level 16でも意図した動作を行えます。

class RoundedBandGraph4 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : View(context, attrs, defStyleAttr) {
    private val paint = Paint().apply { isAntiAlias = true }
    private val leftRect = Rect()
    private val rightRect = Rect()
    private val path = Path()
    var positivePercentage: Int by Delegates.notNull()

    init {
        // Hardware accelerated drawing modelでClipPath()がサポートされていのはAPI Level 18以降
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2
                && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            setLayerType(LAYER_TYPE_SOFTWARE, null);
        }
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        val radius = height / 2
        val positiveRectangleWidth = width * positivePercentage / 100
        val negativeRectangleWidth = width - positiveRectangleWidth
        path.addCircle(radius.toFloat(), radius.toFloat(), radius.toFloat(), Path.Direction.CCW)
        path.addCircle(width - radius.toFloat(), radius.toFloat(), radius.toFloat(), Path.Direction.CCW)
        path.addRect(radius.toFloat(), 0f, width - radius.toFloat(), height.toFloat(), Path.Direction.CCW)
        canvas?.clipPath(path)

        if (positivePercentage >= 50) {
            paint.color = ContextCompat.getColor(context, R.color.major)
        } else {
            paint.color = ContextCompat.getColor(context, R.color.minor)
        }
        rightRect.set(width - positiveRectangleWidth, 0, width, height)
        canvas?.drawRect(rightRect, paint)
        if (positivePercentage >= 50) {
            paint.color = ContextCompat.getColor(context, R.color.minor)
        } else {
            paint.color = ContextCompat.getColor(context, R.color.major)
        }
        leftRect.set(0, 0, negativeRectangleWidth, height)
        canvas?.drawRect(leftRect, paint)
    }
}

pathに両端の円と真ん中の長方形を追加することで描画すべき領域を作成し、その後、単純に左右それぞれ割合に応じた大きさの長方形を描画すれば、意図したグラフが得られます。

まとめ

帯グラフは一見簡単に思える図形ですが、角丸にすることで意外と複雑な実装が必要になります。今回紹介した実装そのものは、一般の帯グラフとは異なる動作をするものではありますが、一般の帯グラフなど様々な図形を実装する際の参考になれば幸いです。

最後に

VASILYでは新しいことに挑戦できるエンジニアを募集しています。 興味のある方は以下のリンクからぜひご応募ください。