LoginSignup
4
4

More than 1 year has passed since last update.

Jetpack Compose で Bar Chart Component を作ってみた

Last updated at Posted at 2023-02-23

はじめに

以前 「Jetpack Compose の Component の中身を調べてみた」 で Jetpack Compose で Component がどのように作られているのかを調べました。分かったことは、比較的簡単なものは Box や Spacer の Modifier を使って予め定義されている Shape オブジェクトや Color オブジェクトを渡すことで Component を作成していました。一方、比較的複雑な図形を用いているものは Canvas を用い drawLine や drawPoints 等を用いて図形を描画し Component を作成していました。

その続きとして、ここでは実際に Jetpack Compose の Component を作っていきたいと思います。作るのは Bar Chart(棒グラフ) Component で単純なものから少しづつ機能を追加して Tutorial 的に作成していきたいと思います。

Bar Chart は比較的複雑な Component ですので、 Canvas を用いて作成することとします。

Android Studio のプロジェクト全体のソースコードを こちら に置いておきます。

x, y 軸を描画する

主なファイル

まずは Canvas を使うための第一歩として x, y 軸を描いてみます。

今回作成する Bar Chart を描画する BarChart Composable 関数の大まかな構成としては一番外側に BoxWithConstraints を置き、その中に Canvas を入れています。

BoxWithConstraints は描画領域のサイズを取得するために使用しています。 Canvas に渡す引数の onDraw 関数でもサイズを取得することはできますが、onDraw 関数は Composable ではないためその中では remember 関数などの Composable 関数が使えません。そのため Canvas の外側で取得した方が色々と都合が良いためこのような構成にしています。

以下のソースコードの様に、まず軸の描画領域 axisArea を設定します。ここでは描画可能な範囲全てを使用することにします。

そして drawAxis 関数で x, y軸を Canvas に描画しています。

@Composable
fun BarChart(
    modifier: Modifier = Modifier,
    attributes: BarChartAttributes = BarChartAttributes()
) {
    BoxWithConstraints(modifier = modifier) {// this:BoxWithConstraintsScope
        // x,y軸の描画エリアを設定する
        val width = with(LocalDensity.current) { maxWidth.toPx() }
        val height = with(LocalDensity.current) { maxHeight.toPx() }
        val axisArea =
            Rect(left = 0f, top = 0f, right = width, bottom = height)

        Canvas(Modifier.fillMaxSize()) { // this: DrawScope
            // x,y軸を描画する
            drawAxis(area = axisArea, attributes = attributes)
        }
    }
}

BarChart 関数に引数として渡している BarChartAttributes は棒グラフの属性をまとめたクラスです。現在は軸の色のみを保持しています。

data class BarChartAttributes(
    val axisLineColor: Color = Color(0x6000_0000)
)

drawAxis 関数は DrawScope をレシーバーとする拡張関数です。このため DrawScope クラスの関数などを呼び出すことができます。ここでは DrawScope.drawLine を使用して x, y軸を描画しています。

// x,y軸を描画する
private fun DrawScope.drawAxis(area: Rect, attributes: BarChartAttributes) {
    drawLine(
        color = attributes.axisLineColor,
        start = Offset(area.left, area.top),
        end = Offset(area.left, area.bottom)
    )
    drawLine(
        color = attributes.axisLineColor,
        start = Offset(area.left, area.bottom),
        end = Offset(area.right, area.bottom)
    )
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview() {
    BarChart(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

x, y軸が描画できていることが確認できました。

データを描画する

主なファイル

次はデータを渡し Bar を描画する機能を追加していきます。データ構造は Datum クラスで表します。value の型は Number と Comparable を実装しているクラスで、具体的には Int, Float などです。 label はデータにつける名前です。

data class Datum<T>(val value: T, val label: String)
    where T : Number, T : Comparable<T>

Bar を描画するのに必要な属性を BarChartAttributes に追加します。 yMin, yMax は y軸の最小値・最大値で null を渡した場合はデータの最小値・最大値で y軸の範囲を設定します。 barInterval は Bar 間の間隔、 barWidth は Bar の幅、 barColor は Bar の色となります。

data class BarChartAttributes<T>(
    val yMin: T? = null,
    val yMax: T? = null,
    val barInterval: Dp = 64.dp, // distance between each bar
    val barWidth: Dp = 48.dp,
    val barColor: Color = Color(0xFF3F_51B5),
    val axisLineColor: Color = Color(0x6000_0000)
) where T : Number, T : Comparable<T>

BarChart の引数に先ほどの Datum のリストを加えます。そして Bar の描画領域 plotArea を設定します。これは今のところ x, y軸の描画領域 axisArea と同じに設定しています。そして y軸の範囲などの y軸の属性を makeYAxisAttributes 関数で求めています。

@Composable
fun <T> BarChart(
    data: List<Datum<T>>,
    modifier: Modifier = Modifier,
    attributes: BarChartAttributes<T> = BarChartAttributes()
) where T : Number, T : Comparable<T> {
    BoxWithConstraints(modifier = modifier) {
        // x,y軸の描画エリアを設定する
        val width = with(LocalDensity.current) { maxWidth.toPx() }
        val height = with(LocalDensity.current) { maxHeight.toPx() }
        val axisArea = Rect(left = 0f, top = 0f, right = width, bottom = height)

        // データの描画エリアを設定する
        val plotArea = Rect(
            left = axisArea.left, top = axisArea.top, right = axisArea.right,
            bottom = axisArea.bottom
        )

        // y軸の範囲を求める
        val yAxisAttributes = makeYAxisAttributes(
                data = data, attributes = attributes)
        // 省略 ...
    }
}

makeYAxisAttributes 関数は YAxisAttributes オブジェクトを返す関数で、これは以下の様に定義されています。今のところは y軸の範囲を表す minValue, maxValue プロパティのみを持ちます。

この値は attributes.yMin, attributes.yMax が設定されていればその値を用い、そうでなければ data から最小値・最大値を求めその値を設定します。

また棒グラフですので、 y軸の範囲内に必ず 0 が含まれるように調整しています。

data class YAxisAttributes(val minValue: Float, val maxValue: Float)

fun <T> makeYAxisAttributes(
    data: List<Datum<T>> = emptyList(),
    attributes: BarChartAttributes<T> = BarChartAttributes()
): YAxisAttributes where T : Number, T : Comparable<T> {
    // チャートのy軸の範囲を決める
    val yMinOrNull = attributes.yMin ?: run { data.minOfOrNull { it.value } }
    val yMin = yMinOrNull?.toFloat() ?: 0f
    val yMaxOrNull = attributes.yMax ?: run { data.maxOfOrNull { it.value } }
    val yMax = yMaxOrNull?.toFloat() ?: 0f

    // 棒グラフのためy軸の範囲には必ず0を含むようにする
    val minValue = min(0f, yMin)
    val maxValue = max(0f, yMax)

    val range = maxValue - minValue
    // 範囲が0だとグラフは書けないので(0,1)の範囲に変更する
    if (range == 0f) return YAxisAttributes(0f, 1f)
    return YAxisAttributes(minValue, maxValue)
}

そして Canvas 内で Bar を描画します。

@Composable
fun <T> BarChart(
    data: List<Datum<T>>,
    modifier: Modifier = Modifier,
    attributes: BarChartAttributes<T> = BarChartAttributes()
) where T : Number, T : Comparable<T> {
    BoxWithConstraints(modifier = modifier) {
        // 省略 ...
        Canvas(Modifier.fillMaxSize()) {
            // 省略 ...
            // データを描画する
            drawData(
                data = data,
                yAxisAttributes = yAxisAttributes,
                area = plotArea,
                attributes = attributes
            )
        }
    }
}

DrawScope.drawData 関数ではグラフの座標系と Canvas の pixel 単位の座標系の変換を行い、 drawRect 関数で Bar を描画します。また描画領域から Bar がはみ出ないように clipRect 関数を用いて制限しています。

private fun <T> DrawScope.drawData(
    data: List<Datum<T>>,
    yAxisAttributes: YAxisAttributes,
    area: Rect,
    attributes: BarChartAttributes<T>
) where T : Number, T : Comparable<T> {
    val yMin = yAxisAttributes.minValue
    val yMax = yAxisAttributes.maxValue

    // データ値を座標に変換する係数を計算 y = ax + b
    val yRange = yMax - yMin
    val a = (area.top - area.bottom) / yRange
    val b = (area.bottom * yMax - area.top * yMin) / yRange
    val barInterval = attributes.barInterval.toPx()
    val barWidth = attributes.barWidth.toPx()
    val xStart = area.left + (barInterval - barWidth) / 2f
    clipRect(
        left = area.left, top = area.top, right = area.right, bottom = area.bottom) {
        data.forEachIndexed { index, datum ->
            // 描画するbarの座標を計算
            val t = a * datum.value.toFloat()
            val y = t + b
            val top = min(y, b)
            val barHeight = abs(t)
            val left = xStart + barInterval * index
            drawRect(
                color = attributes.barColor,
                topLeft = Offset(x = left, y = top),
                size = Size(width = barWidth, height = barHeight)
            )
        }
    }
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview2() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(2, "d2"),
            Datum(3, "d3"),
            Datum(4, "d4"),
            Datum(5, "d5"),
            Datum(6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}
@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview3() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(-2, "d2"),
            Datum(3, "d3"),
            Datum(-4, "d4"),
            Datum(5, "d5"),
            Datum(-6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

Bar が描画できていることが確認できました。

データラベルを描画する

主なファイル

次はデータラベルを描画します。まずはデータラベルを描画するのに必要な属性を BarChartAttributes に追加します。ここでは文字列の色とフォントの大きさを指定しています。

data class BarChartAttributes<T>(
    // 省略 ...
    val dataLabelTextColor: Color = Color(0xdd00_0000),
    val dataLabelTextSize: TextUnit = 12.sp
) where T : Number, T : Comparable<T>

データラベルを描画するにまず文字列のサイズを計測します。Jetpack Compose はその目的のために TextMeasurer クラスを用意しています。オブジェクトの作成には rememberTextMeasurer 関数を使用します。そして measureDataLabel 関数でデータラベルを計測します。この計測結果は後ほど文字列を描画する際に使用します。またラベルの最大の高さを取得してこの後ラベルの描画領域を確保するために使用します。

fun <T> BarChart(
   // 省略 ...
) where T : Number, T : Comparable<T> {
    BoxWithConstraints(modifier = modifier) {
        // 文字列のサイズの計測器を準備する
        val textMeasurer = rememberTextMeasurer()

        // データラベルを計測する
        val dataLabelLayoutResults =
            measureDataLabel(data = data, attributes = attributes,
            textMeasurer = textMeasurer)
        // データラベルの最大の高さを取得する
        val maxDataLabelHeight = dataLabelLayoutResults.maxOfOrNull {
            it.size.height
        } ?: 0
        // 省略 ...

measureDataLabel 関数では個々のデータのラベルを取り出し TextMeasurer.measure 関数にラベルの文字列を AnnotatedString 化したものと、文字の色、フォントのサイズを渡してラベル文字列を計測します。計測の結果は TextLayoutResult として返されます。ここでは全てのラベルを計測し TextLayoutResult のリストとして結果を返しています。

@OptIn(ExperimentalTextApi::class)
private fun <T> measureDataLabel(
    data: List<Datum<T>>,
    attributes: BarChartAttributes<T>,
    textMeasurer: TextMeasurer
): List<TextLayoutResult> where T : Number, T : Comparable<T> {
    val textLayoutResults = mutableListOf<TextLayoutResult>()
    data.forEach {
        val label = it.label
        textLayoutResults.add(
            textMeasurer.measure(
                text = AnnotatedString(label),
                style = TextStyle(
                    color = attributes.dataLabelTextColor,
                    fontSize = attributes.dataLabelTextSize
                )
            )
        )
    }
    return textLayoutResults
}

データラベルを描画する領域を確保するために x, y軸 を描画する領域の bottom 、つまり x軸をラベルの高さ分上にずらし、ラベルの描画領域を確保します。そして空いた領域をデータラベルの描画エリア dataLabelArea として設定します。

// x,y軸の描画エリアを設定する
val width = with(LocalDensity.current) { maxWidth.toPx() }
val height = with(LocalDensity.current) { maxHeight.toPx() }
val axisArea = Rect(left = 0f, top = 0f, right = width, bottom = height)
val axisArea = Rect(
    left = 0f, top = 0f, right = width,
    bottom = height - maxDataLabelHeight // データラベル分、軸の位置をずらす
)

// データの描画エリアを設定する
val plotArea = Rect(
    left = axisArea.left, top = axisArea.top, right = axisArea.right,
    bottom = axisArea.bottom
)
// データラベルの描画エリアを設定する
val dataLabelArea = Rect(
    left = plotArea.left, top = plotArea.bottom, right = plotArea.right,
    bottom = plotArea.bottom + maxDataLabelHeight
)

DrawScope.drawData 関数を修正し、ラベルを描画するようにします。ラベルの描画位置は Bar に対しセンター合わせとします。また描画領域のクリップは Bar の描画領域とラベルの描画領域を Rect.union 関数で両方含む領域に拡大しています。ラベルの描画は文字列を計測した結果である TextLayoutResult オブジェクトと表示位置を drawText に渡して描画します。 文字の色は文字列の計測時に TextMeasurer.measure 関数に渡しているのでここでは指定していません。

@OptIn(ExperimentalTextApi::class)
private fun <T> DrawScope.drawData(
    data: List<Datum<T>>,
    dataLabelLayoutResults: List<TextLayoutResult>,
    yAxisAttributes: YAxisAttributes,
    plotArea: Rect,
    dataLabelArea: Rect,
    attributes: BarChartAttributes<T>
) where T : Number, T : Comparable<T> {
    // 省略 ...
    // clipの範囲をプロット領域とラベル領域の両方を含むようにする
    val unionArea = plotArea.union(dataLabelArea)
    clipRect(
        left = unionArea.left,
        top = unionArea.top,
        right = unionArea.right,
        bottom = unionArea.bottom
    ) {
        data.forEachIndexed { index, datum ->
            // 省略 ...
            // ラベルの座標を計算
            val labelLayoutResult = dataLabelLayoutResults[index]
            val labelTop = dataLabelArea.top
            val labelLeft =
                    barLeft + barWidth / 2f - labelLayoutResult.size.width / 2f
            // ラベルを描画
            drawText(
                textLayoutResult = labelLayoutResult,
                topLeft = Offset(labelLeft, labelTop)
            )
        }
    }
}

Rect.union 関数は相当する関数が Jetpack Compose に見つからなかったので自作しています。

private fun Rect.union(other: Rect): Rect {
    return Rect(
        min(left, other.left),
        min(top, other.top),
        max(right, other.right),
        max(bottom, other.bottom)
    )
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview2() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(2, "d2"),
            Datum(3, "d3"),
            Datum(4, "d4"),
            Datum(5, "d5"),
            Datum(6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}
@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview3() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(-2, "d2"),
            Datum(3, "d3"),
            Datum(-4, "d4"),
            Datum(5, "d5"),
            Datum(-6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

データラベルが描画できていることが確認できました。

グリッドを描画する

主なファイル

次はグリッド線およびグリッドの値を描画します。まずはグリッド線の位置を決める必要があるので y軸の範囲からグリッド線の位置を決めるよう makeYAxisAttributes 関数を修正します。結果は YAxisAttributesgridList プロパティに保存するようにします。グリッド位置を求める方法はこの記事の目的とはあまり関係ないので説明は省きます。概略としては grid 間隔を 1, 2, 5, 10, 20, 50, 100, ... 等から選び、引数で渡す gridCount 本を超えない程度にしています。

data class YAxisAttributes(
    val minValue: Float, val maxValue: Float, val gridList: List<Float>)

fun <T> makeYAxisAttributes(
    data: List<Datum<T>> = emptyList(),
    attributes: BarChartAttributes<T> = BarChartAttributes(),
    gridCount: Float = 7f // 目標とするgrid線の数。実際にはこれより小さくなる
): YAxisAttributes where T : Number, T : Comparable<T> {
    // チャートのy軸の範囲を決める
    val yMinOrNull = attributes.yMin ?: run { data.minOfOrNull { it.value } }
    val yMin = yMinOrNull?.toFloat() ?: 0f
    val yMaxOrNull = attributes.yMax ?: run { data.maxOfOrNull { it.value } }
    val yMax = yMaxOrNull?.toFloat() ?: 0f

    // 棒グラフのためy軸の範囲には必ず0を含むようにする
    val minValue = min(0f, yMin)
    val maxValue = max(0f, yMax)

    val range = maxValue - minValue
    // 範囲が0だとグラフは書けないので(0,1)の範囲に変更する
    if (range == 0f) return YAxisAttributes(0f, 1f, emptyList())

    // grid間隔を計算する。1, 2, 5, 10, 20, 50, 100, ... 等から選ぶ。
    // grid間隔はgrid線がgridCount個程度になるように調整する
    var gridIntervalOrder = 1f
    // まずはgrid線がgridCount個以上になるようにする
    while (true) {
        if (gridCount <= range / gridIntervalOrder + 1) break
        // grid幅の桁を一つ下げる
        gridIntervalOrder /= 10f
    }
    // grid間隔を少しずつ大きくしてgrid線がgridCount個以下になるように調整する
    val factors = listOf(1f, 2f, 5f)
    val gridInterval: Float
    loop@ while (true) {
        for (factor in factors) {
            val interval = factor * gridIntervalOrder
            if (range / interval + 1 <= gridCount) {
                gridInterval = interval
                break@loop
            }
        }
        // grid幅の桁を一つ上げる
        gridIntervalOrder *= 10f
    }

    // grid位置を求め配列に追加していく
    val gridList = mutableListOf<Float>()
    val start = (minValue / gridInterval).toInt()
    val end = (maxValue / gridInterval).toInt() + 1
    for (i in start until end) {
        gridList.add(i * gridInterval)
    }
    return YAxisAttributes(minValue, maxValue, gridList)
}

またグリッド線の色やグリッド値を表示する文字列の色やフォントサイズ、数値をどの様にフォーマットするかに関する属性を BarChartAttributes に追加します。数値のフォーマットに関しては DecimalFormat のパターン文字列を gridValueFormatPattern プロパティに渡します。

data class BarChartAttributes<T>(
    // 省略 ...
    val gridLineColor: Color = Color(0x6000_0000),
    val gridValueTextColor: Color = Color(0xdd00_0000),
    val gridValueTextSize: TextUnit = 12.sp,
    val gridValueFormatPattern: String? = null
) where T : Number, T : Comparable<T>

データラベルの時と同様に TextMesurer を用い measureGridValue 関数によってグリッド値の文字列のサイズを計測します。またグリッド値の文字列の最大の幅を取得してこの後グリッド値の描画領域を確保するために使用します。

fun <T> BarChart(
    // 省略 ...
) where T : Number, T : Comparable<T> {
    BoxWithConstraints(modifier = modifier) {
        // 文字列のサイズの計測器を準備する
        val textMeasurer = rememberTextMeasurer()

        // 省略 ...

        // y軸の範囲とgridを求める
        val yAxisAttributes = makeYAxisAttributes(
                data = data, attributes = attributes)

        // grid値を表す文字列を計測する
        val gridValueLayoutResults = measureGridValue(
            yAxisAttributes = yAxisAttributes,
            attributes = attributes,
            textMeasurer = textMeasurer
        )
        // Grid値の最大の幅を取得する
        val maxGridValueWidth = gridValueLayoutResults.maxOfOrNull {
            it.size.width
        } ?: 0

measureGridValue 関数では個々のグリッド値を取り出し TextMeasurer.measure 関数でグリッド値の文字列を計測し、その結果を TextLayoutResult として返します。全てのグリッド値の文字列を計測し TextLayoutResult のリストとして結果を返しています。

@OptIn(ExperimentalTextApi::class)
private fun <T> measureGridValue(
    yAxisAttributes: YAxisAttributes,
    attributes: BarChartAttributes<T>,
    textMeasurer: TextMeasurer
): List<TextLayoutResult> where T : Number, T : Comparable<T> {
    val textLayoutResults = mutableListOf<TextLayoutResult>()
    val formatter = attributes.gridValueFormatPattern?.let {
        DecimalFormat(it)
    } ?: NumberFormat.getInstance()
    yAxisAttributes.gridList.forEach {
        val label = formatter.format(it)
        textLayoutResults.add(
            textMeasurer.measure(
                text = AnnotatedString(label),
                style = TextStyle(
                    color = attributes.gridValueTextColor,
                    fontSize = attributes.gridValueTextSize
                )
            )
        )
    }
    return textLayoutResults
}

グリッド値を描画する領域を確保するために x, y軸 を描画する領域の left 、つまり y軸をグリッド値の幅の分だけ右にずらし、グリッド値の描画領域を確保します。また y軸とグリッド値の間にちょっと隙間を空けるために 8 dp のパディングを設けています。ここでは固定にしていますが、プログラマブルにしたい場合は BarChartAttributes クラスに追加すれば良いでしょう。そしてグリッド線と値を表示する領域 gridLineArea , gridValueArea を設定します。

// grid値の文字列とy軸の間のpadding
private val PADDING = 8.dp
// x,y軸の描画エリアを設定する
val width = with(LocalDensity.current) { maxWidth.toPx() }
val height = with(LocalDensity.current) { maxHeight.toPx() }
val padding = with(LocalDensity.current) { PADDING.toPx() }
val axisArea = Rect(
    left = maxGridValueWidth.toFloat() + padding, // grid値表示分、軸の位置をずらす
    top = 0f,
    right = width,
    bottom = height - maxDataLabelHeight // データラベル分、軸の位置をずらす
)

// 省略 ...

// グリッド線の描画エリアを設定する
val gridLineArea = Rect(
    left = plotArea.left, top = plotArea.top, right = plotArea.right,
    bottom = plotArea.bottom
)
// グリッド値の描画エリアを設定する
val gridValueArea = Rect(
    left = 0f, top = gridLineArea.top, right = gridLineArea.left - padding,
    bottom = gridLineArea.bottom
)

そして DrawScope.drawGrid 関数でグリッド線と値を描画します。グリッド値を Canvas の座標に変換し、グリッド線は drawLine で、グリッド値は drawText で描画しています。

@OptIn(ExperimentalTextApi::class)
private fun <T> DrawScope.drawGrid(
    yAxisAttributes: YAxisAttributes,
    gridValueLayoutResults: List<TextLayoutResult>,
    gridLineArea: Rect,
    gridValueArea: Rect,
    attributes: BarChartAttributes<T>
) where T : Number, T : Comparable<T> {
    val yMin = yAxisAttributes.minValue
    val yMax = yAxisAttributes.maxValue

    // データ値を座標に変換する係数を計算 y = ax + b
    val yRange = yMax - yMin
    val a = (gridLineArea.top - gridLineArea.bottom) / yRange
    val b = (gridLineArea.bottom * yMax - gridLineArea.top * yMin) / yRange

    yAxisAttributes.gridList.forEachIndexed { index, gridValue ->
        val y = a * gridValue + b
        // grid線を描画する
        drawLine(
            color = attributes.gridLineColor,
            start = Offset(x = gridLineArea.left, y = y),
            end = Offset(x = gridLineArea.right, y = y)
        )
        // grid値を描画する
        val valueSize = gridValueLayoutResults[index].size
        val valueLeft = gridValueArea.right - valueSize.width
        val valueTop = y - valueSize.height / 2
        drawText(
            textLayoutResult = gridValueLayoutResults[index],
            topLeft = Offset(x = valueLeft, y = valueTop)
        )
    }
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview2() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(2, "d2"),
            Datum(3, "d3"),
            Datum(4, "d4"),
            Datum(5, "d5"),
            Datum(6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}
@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview3() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(-2, "d2"),
            Datum(3, "d3"),
            Datum(-4, "d4"),
            Datum(5, "d5"),
            Datum(-6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

また BarChartAttributes.gridValueFormatPattern を用いれば「円」等の単位もグリッド値に付けることができます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview5() {
    BarChart(
        data = listOf(
            Datum(1000000, "d1"),
            Datum(2000000, "d2"),
            Datum(3000000, "d3"),
            Datum(4000000, "d4"),
            Datum(5000000, "d5"),
            Datum(6000000, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        attributes = BarChartAttributes(gridValueFormatPattern = ",##0円"),
    )
}

データ値を描画する

主なファイル

Bar の上(負の値の場合は下)にデータ値を描画します。まずはデータ値を描画するのに必要な属性を BarChartAttributes に追加します。ここでは文字の色やフォントサイズ、数値をどの様にフォーマットするかに関する属性を BarChartAttributes に追加します。数値のフォーマットに関しては DecimalFormat のパターン文字列を dataValueFormatPattern プロパティに渡します。

data class BarChartAttributes<T>(
    // 省略 ...
    val dataValueTextColor: Color = Color(0xdd00_0000),
    val dataValueTextSize: TextUnit = 12.sp,
    val dataValueFormatPattern: String? = null
) where T : Number, T : Comparable<T>

これまでと同様に TextMesurer を用い measureDataValue 関数によってデータ値の文字列のサイズを計測します。またデータ値の文字列の最大の高さを取得してこの後データ値の描画領域を確保するために使用します。

// データ値を表す文字列を計測する
val dataValueLayoutResults = measureDataValue(
        data = data, attributes = attributes, textMeasurer = textMeasurer)
// データ値を表す文字列の最大の高さを取得する
val maxDataValueHeight = dataValueLayoutResults.maxOfOrNull {
    it.size.height
} ?: 0

measureDataValue 関数でもこれまでと同様に文字列を TextMeasurer.measure 関数で計測し、その結果を TextLayoutResult として取得します。ここでは全てのデータ値を表す文字列を計測し TextLayoutResult のリストとして結果を返します。

@OptIn(ExperimentalTextApi::class)
private fun <T> measureDataValue(
    data: List<Datum<T>>,
    attributes: BarChartAttributes<T>,
    textMeasurer: TextMeasurer
): List<TextLayoutResult> where T : Number, T : Comparable<T> {
    val textLayoutResults = mutableListOf<TextLayoutResult>()
    val formatter = attributes.dataValueFormatPattern?.let {
        DecimalFormat(it)
    } ?: NumberFormat.getInstance()
    data.forEach {
        val value = formatter.format(it.value)
        textLayoutResults.add(
            textMeasurer.measure(
                text = AnnotatedString(value),
                style = TextStyle(
                    color = attributes.dataLabelTextColor,
                    fontSize = attributes.dataLabelTextSize
                )
            )
        )
    }
    return textLayoutResults
}

データ値を描画する領域を確保するために Bar の描画領域の上下にスペースを設けます。但し 0 又は正のデータが無い場合は上部にスペースを設ける必要は無いので上部のスペースは 0 とします。負のデータが無い場合も同様に下部のスペースは 0 とします。そして必要に応じて Bar の描画領域の top を下げ、 bottom を上げることでデータ値の描画領域を確保します。またデータ値の描画領域としては x, y軸の描画領域そのままを渡します。

// データの描画エリアを設定する
val posDataValueSpace =
    data.find { 0f <= it.value.toFloat() }?.run { maxDataValueHeight } ?: 0
val negDataValueSpace =
    data.find { it.value.toFloat() < 0f }?.run { maxDataValueHeight } ?: 0
val plotArea = Rect(
    left = axisArea.left,
    top = axisArea.top + posDataValueSpace,
    right = axisArea.right,
    bottom = axisArea.bottom - negDataValueSpace
)

// 省略 ...

// データ値の描画エリアを設定する
val dataValueArea = Rect(
    left = axisArea.left,
    top = axisArea.top,
    right = axisArea.right,
    bottom = axisArea.bottom
)

そして DrawScope.drawData 関数を修正し、データ値を描画するようにします。データ値の水平方向の描画位置は Bar に対しセンター合わせとし、0 又は正の値の場合は Bar の上部に、負の値の場合は Bar の下部にします。また描画領域のクリップは Bar の描画領域とラベルの描画領域、データ値の描画領域を Rect.union 関数で全て含む領域に拡大しています。データ値の文字列は drawText に文字列を計測した結果である TextLayoutResult オブジェクトと表示位置を渡して描画します。 文字の色は文字列の計測時に TextMeasurer.measure 関数に渡しているのでここでは指定していません。

@OptIn(ExperimentalTextApi::class)
private fun <T> DrawScope.drawData(
    data: List<Datum<T>>,
    dataLabelLayoutResults: List<TextLayoutResult>,
    dataValueLayoutResults: List<TextLayoutResult>,
    yAxisAttributes: YAxisAttributes,
    plotArea: Rect,
    dataLabelArea: Rect,
    dataValueArea: Rect,
    attributes: BarChartAttributes<T>
) where T : Number, T : Comparable<T> {
    val yMin = yAxisAttributes.minValue
    val yMax = yAxisAttributes.maxValue

    // データ値を座標に変換する係数を計算 y = ax + b
    val yRange = yMax - yMin
    val a = (plotArea.top - plotArea.bottom) / yRange
    val b = (plotArea.bottom * yMax - plotArea.top * yMin) / yRange
    val barInterval = attributes.barInterval.toPx()
    val barWidth = attributes.barWidth.toPx()
    val xStart = plotArea.left + (barInterval - barWidth) / 2f
    // clipの範囲をプロット領域とラベル領域、データ値領域の全てを含むようにする
    val unionArea = plotArea.union(dataLabelArea).union(dataValueArea)
    clipRect(
        left = unionArea.left,
        top = unionArea.top,
        right = unionArea.right,
        bottom = unionArea.bottom
    ) {
        data.forEachIndexed { index, datum ->
            // 描画するbarの座標を計算
            val t = a * datum.value.toFloat()
            val y = t + b
            val barTop = min(y, b)
            val barHeight = abs(t)
            val barLeft = xStart + barInterval * index
            drawRect(
                color = attributes.barColor,
                topLeft = Offset(x = barLeft, y = barTop),
                size = Size(width = barWidth, height = barHeight)
            )

            // ラベルの座標を計算
            val labelLayoutResult = dataLabelLayoutResults[index]
            val labelTop = dataLabelArea.top
            val labelLeft =
                    barLeft + barWidth / 2f - labelLayoutResult.size.width / 2f
            // ラベルを描画
            drawText(
                textLayoutResult = labelLayoutResult,
                topLeft = Offset(labelLeft, labelTop)
            )

            // データ値の描画
            val valueLayoutResult = dataValueLayoutResults[index]
            val valueLeft = barLeft + (barWidth - valueLayoutResult.size.width) / 2
            val valueTop =
                if (0f <= datum.value.toFloat())
                    barTop - valueLayoutResult.size.height
                else barTop + barHeight
            drawText(
                textLayoutResult = valueLayoutResult,
                topLeft = Offset(valueLeft, valueTop)
            )
        }
    }
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview2() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(2, "d2"),
            Datum(3, "d3"),
            Datum(4, "d4"),
            Datum(5, "d5"),
            Datum(6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}
@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview3() {
    BarChart(
        data = listOf(
            Datum(1, "d1"),
            Datum(-2, "d2"),
            Datum(3, "d3"),
            Datum(-4, "d4"),
            Datum(5, "d5"),
            Datum(-6, "d6")
        ),
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

スクロール機能を実装

主なファイル

データが多く全てのデータを表示しきれない場合はスクロール機能があると良いでしょう。スクロール機能を実装するには Modifier.scrollable 関数を使うと便利です。こちら に簡単な説明があります。これを Canvas に渡す Modifier に追加します。orientation 引数は Orientation 型のオブジェクトを渡しスクロール方向を指定します。ここでは Orientation.Horizontal を渡し水平方向を指定しています。また state 引数には ScrollableState 型のオブジェクトを渡します。

Canvas(
    Modifier
        .fillMaxSize()
        .scrollable(
            orientation = Orientation.Horizontal,
            state = scrollableState
        )
) {

このオブジェクトは rememberScrollableState 関数で作成します。

@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState

この関数に渡す consumeScrollDelta 関数では引数として各スクロールステップでのスクロール距離を受け取ります。そしてそのスクロール距離のうち、このコンポーネントで消費した量を関数の返り値とします。スクロールするコンポーネントが複数ネストしている場合などの特別な場合を除けば通常は受け取ったスクロール距離をそのまま返せば良いでしょう。

// 全データのbarを描画するのに必要な幅を計算する
val barInterval = with(LocalDensity.current) { attributes.barInterval.toPx() }
val totalDataWidth = barInterval * data.size
// スクロールが行き過ぎないよう、最小のスクロール値(負の値。絶対値としては最大)を計算する
val minOffset = min(plotArea.width - totalDataWidth, 0f)
// Canvasからスクロールの状態を取得するstate
val scrollableState = rememberScrollableState { delta ->
    scrollOffset = max(min(scrollOffset + delta, 0f), minOffset)
    delta // このComponentで消費したスクロール量を返す。ここでは全てを消費している
}

スクロール距離は細かく分割されて受け取るので、それを積算するために scrollOffset 変数を用意しています。これは再コンポーズの際に再初期化されては困るので remember 関数で記憶するようにします。またスクロールしたら当然描画し直すので MutableState 型の変数にして、この値が変わったら再コンポーズするようにします。

// scroll値。最大値 0。スクロールするほど負の方向に行く
var scrollOffset by remember { mutableStateOf(0f) }

またスクロールが端に達したらスクロールを停止させるために ScrollableState.stopScroll 関数を使用しています。この関数は suspend 関数ですので LaunchedEffect で囲っています。

// スクロールが端に達したらスクロールを停止する
LaunchedEffect(key1 = scrollOffset) {
    if (scrollOffset == 0f || scrollOffset == minOffset) scrollableState.stopScroll()
}

そして DrawScope.drawData 関数を修正し、スクロールによるオフセットを考慮して描画するようにします。

@OptIn(ExperimentalTextApi::class)
private fun <T> DrawScope.drawData(
    // 省略 ...
    offset: Float,
    // 省略 ...
) where T : Number, T : Comparable<T> {
    // 省略 ...

        data.forEachIndexed { index, datum ->
            // 描画するbarの座標を計算
            // 省略 ...
            val barLeft = xStart + barInterval * index + offset
            drawRect(
            // 省略 ...
            )
            // 省略 ...
        }
    }
}

全体的には以下の様になります。

fun <T> BarChart(
    // 省略 ...
) where T : Number, T : Comparable<T> {
    BoxWithConstraints(modifier = modifier) {
        // 省略 ...

        // scroll値。最大値 0。スクロールするほど負の方向に行く
        var scrollOffset by remember { mutableStateOf(0f) }
        // 省略 ...

        // 全データのbarを描画するのに必要な幅を計算する
        val barInterval = with(LocalDensity.current) { attributes.barInterval.toPx() }
        val totalDataWidth = barInterval * data.size
        // スクロールが行き過ぎないよう、最小のスクロール値(負の値。絶対値としては最大)を計算する
        val minOffset = min(plotArea.width - totalDataWidth, 0f)
        // Canvasからスクロールの状態を取得するstate
        val scrollableState = rememberScrollableState { delta ->
            scrollOffset = max(min(scrollOffset + delta, 0f), minOffset)
            delta
        }
        // スクロールが端に達したらスクロールを停止する
        LaunchedEffect(key1 = scrollOffset) {
            if (scrollOffset == 0f || scrollOffset == minOffset)
                scrollableState.stopScroll()
        }

        Canvas(
            Modifier
                .fillMaxSize()
                .scrollable(
                    orientation = Orientation.Horizontal,
                    state = scrollableState
                )
        ) {
            // 省略 ...

            // データを描画する
            drawData(
                data = data,
                dataLabelLayoutResults = dataLabelLayoutResults,
                dataValueLayoutResults = dataValueLayoutResults,
                yAxisAttributes = yAxisAttributes,
                plotArea = plotArea,
                dataLabelArea = dataLabelArea,
                dataValueArea = dataValueArea,
                offset = scrollOffset, // offset を追加
                attributes = attributes
            )
            // 省略 ...

        }
    }
}

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview6() {
    val data = mutableListOf<Datum<Int>>()
    repeat(100) {
        data.add(Datum(it + 1, "d${it + 1}"))
    }
    BarChart(
        data = data,
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

表示されているデータに応じてy軸の範囲を変更するようにする

主なファイル

前節の結果を見ると y軸の範囲に対して Bar の高さがずいぶん低くなってしまっています。これは全てのデータを見ると最大値が 100 なので自動スケーリングの場合 y軸の範囲が 0 〜 100 になるためです。表示されている範囲のデータに対して y軸の範囲を調整するようにしてみましょう。

まず表示されているデータの範囲を表すクラスを定義します。このクラスは表示されているデータの範囲をインデックス fromIndex, toIndex で保持するクラスです。 toIndex の指すデータは範囲外(exclusive)となり実際には fromIndextoIndex - 1 が表示範囲となります。

// 表示されているデータのインデックスを保持するクラス
private data class DisplayedDataRange(val fromIndex: Int, val toIndex: Int)

getDisplayedDataRange 関数で表示されているデータの範囲を取得します。

private fun getDisplayedDataRange(
    scrollOffset: Float,
    dataNum: Int,
    plotAreaWidth: Float,
    barInterval: Float
): DisplayedDataRange {
    val fromIndex = max(0, (-scrollOffset / barInterval).toInt())
    val toIndex =
        min(dataNum, fromIndex + ceil(plotAreaWidth / barInterval).toInt() + 1)
    return DisplayedDataRange(fromIndex, toIndex)
}
// 描画領域のサイズ
val width = with(LocalDensity.current) { maxWidth.toPx() }
val height = with(LocalDensity.current) { maxHeight.toPx() }
// scroll値。最大値 0。スクロールするほど負の方向に行く
var scrollOffset by remember { mutableStateOf(0f) }
// 表示されるデータの範囲をインデックスで取得する
val barInterval = with(LocalDensity.current) { attributes.barInterval.toPx() }
val displayedDataRange = getDisplayedDataRange(
    scrollOffset = scrollOffset,
    dataNum = data.size,
    plotAreaWidth = width,
    barInterval = barInterval
)

この diplayedDataRange を用いて表示される範囲のデータを元のデータから切り出します。

// 表示されるデータを取得
val displayedData =
        data.subList(displayedDataRange.fromIndex, displayedDataRange.toIndex)

これを用いて y軸の範囲を計算します。なおスクロール中に y軸の範囲が頻繁に変更されるのは煩わしいので、ここではスクロールが終わった時点で y軸の範囲を再計算するようにしています。そのために isScrollInProgress State 変数でスクロールの状態を保持し、これを remember 関数の key1 引数に渡すことで、スクロールの状態が変わった時に y軸の範囲を再計算するようにしています。

// 現在scrollしているかを示すフラグ
var isScrollInProgress by remember { mutableStateOf(false) }
// y軸の範囲とgridを求める
val yAxisAttributes = remember(key1 = isScrollInProgress) {
    makeYAxisAttributes(data = displayedData, attributes = attributes)
}

// 省略 ...

// スクロール中かどうかをStateに保存する
isScrollInProgress = scrollableState.isScrollInProgress

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview6() {
    val data = mutableListOf<Datum<Int>>()
    repeat(100) {
        data.add(Datum(it + 1, "d${it + 1}"))
    }
    BarChart(
        data = data,
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

y軸の範囲の変化をスムーズにする

主なファイル

前節では表示されているデータに応じて y軸の範囲が自動的に調整されるようにしました。この時 y軸の範囲は瞬時に切り替わっています。これでも良いのですが、変化がスムーズな方が好まれる場合もあります。これをアニメーション機能を用いて実現しましょう。

まずアニメーション化する値、すなわち徐々に変化させる値のクラスを作成します。この場合は y軸の範囲ですので y軸の最小値と最大値を持つクラスを作成します。

data class YAxisRange(val minValue: Float, val maxValue: Float)

そして makeYAxisAttributes 関数から y軸の範囲を作成する部分を切り出し、それを makeYAxisRange 関数として実装します。

fun <T> makeYAxisRange(
    data: List<Datum<T>> = emptyList(),
    attributes: BarChartAttributes<T> = BarChartAttributes()
): YAxisRange where T : Number, T : Comparable<T> {
    // チャートのy軸の範囲を決める
    val yMinOrNull = attributes.yMin ?: run { data.minOfOrNull { it.value } }
    val yMin = yMinOrNull?.toFloat() ?: 0f
    val yMaxOrNull = attributes.yMax ?: run { data.maxOfOrNull { it.value } }
    val yMax = yMaxOrNull?.toFloat() ?: 0f

    // 棒グラフのためy軸の範囲には必ず0を含むようにする
    val minValue = min(0f, yMin)
    val maxValue = max(0f, yMax)

    return YAxisRange(minValue, maxValue)
}

fun makeYAxisAttributes(
    yAxisRange: YAxisRange, // 引数としてアニメーション化したYAxisRangeを受け取る
    // 省略 ...
): YAxisAttributes {
    // y軸の範囲を作成する部分はmakeYAxisRange関数へ移行
    val minValue = yAxisRange.minValue
    val maxValue = yAxisRange.maxValue

    // 以下は以前と同様 ...
}

上記の makeYAxisRange 関数で y軸の範囲を YAxisRange オブジェクトとして計算します。

// y軸の範囲とgridを求める
val yAxisRange = makeYAxisRange(data = displayedData, attributes = attributes)

そしてその値を初期値として Animatable 型の変数に変換します。typeConverter 引数は TwoWayConverter 型で、同名の TwoWayConverter 関数を用いて作成します。これはオブジェクトをベクトル化し、またその逆にベクトル化した値を元のオブジェクトに戻す役目をします。ここでは初期値として与えている YAxisRange がアニメーション化するプロパティを 2つ持っていますので、2次元のベクトルに変換しています。具体的には TwoWayConverter のコンストラクタの convertToVector 引数に渡すラムダ関数で YAxisRange オブジェクトを AnimationVector2D オブジェクトに変換しています。また convertFromVector に渡すラムダ関数で AnimationVector2D オブジェクトから YAxisRange オブジェクトに戻しています。

// y軸の範囲をアニメーション化する
val animatedYAxisRange = remember {
    Animatable(
        initialValue = yAxisRange,
        typeConverter = TwoWayConverter(
            convertToVector = {
                AnimationVector2D(it.minValue, it.maxValue)
            }, convertFromVector = {
                YAxisRange(it.v1, it.v2)
            })
    )
}
fun <T : Any?, V : AnimationVector> TwoWayConverter(
    convertToVector: (T) -> V,
    convertFromVector: (V) -> T
): TwoWayConverter<T, V>

そしてスクロールが終了したらアニメーションを開始するために Animatable.animateTo 関数を使用しています。この関数は suspend 関数ですので LaunchedEffect で囲っています。

LaunchedEffect(key1 = isScrollInProgress) {
    animatedYAxisRange.animateTo(
        targetValue = yAxisRange
    )
}

最後に YAxisRange オブジェクトをアニメーション化した値を用いて y軸の範囲を計算しています。ここでは前節で remember で囲っていたのを元に戻しています。これは y軸の範囲の変化をアニメーション化したので再コンポーズされる度に再計算させるためです。

val yAxisAttributes = makeYAxisAttributes(yAxisRange = animatedYAxisRange.value)

結果を確認してみます。

@Preview(widthDp = 400, heightDp = 400)
@Composable
private fun BarChartPreview6() {
    val data = mutableListOf<Datum<Int>>()
    repeat(100) {
        data.add(Datum(it + 1, "d${it + 1}"))
    }
    BarChart(
        data = data,
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    )
}

処理を効率化する

主なファイル

ここまで作成してきたプログラムは大きな問題が一つあります。これまでは全てのデータを用いて処理を行ってきました。そのためデータが多い場合処理する量が多すぎて画面の更新が滞ってしまうことです。実際データ数が 10,000個程度になるとスクロールしてもアプリが反応しなくなってしまいます。そこで表示されるデータのみを用いて処理を行うように修正してみましょう。

表示される範囲のデータは以前取得しました。

// 表示されるデータを取得
val displayedData =
        data.subList(displayedDataRange.fromIndex, displayedDataRange.toIndex)

各処理に渡すデータを以下の様にこれに置き換えていきます。 drawData 関数に渡すデータを displayedData に置き換えた場合は displayedDataRange.fromIndex 個のデータが先頭から減っていますから、その分スクロールによるオフセットを調整する必要があります。

// データラベルを計測する
val dataLabelLayoutResults =
    measureDataLabel(
        data = displayedData,
        attributes = attributes,
        textMeasurer = textMeasurer
    )

// 省略 ...

// データ値を表す文字列を計測する
val dataValueLayoutResults =
    measureDataValue(
        data = displayedData,
        attributes = attributes,
        textMeasurer = textMeasurer
    )

// 省略 ...

// データの描画エリアを設定する
val posDataValueSpace =
    displayedData.find { 0f <= it.value.toFloat() }?.run { maxDataValueHeight } ?: 0
val negDataValueSpace =
    displayedData.find { it.value.toFloat() < 0f }?.run { maxDataValueHeight } ?: 0
val plotArea = Rect(
    left = axisArea.left,
    top = axisArea.top + posDataValueSpace,
    right = axisArea.right,
    bottom = axisArea.bottom - negDataValueSpace
)

// 省略 ...

Canvas(
 // 省略 ...
) {
    // 省略 ...
    // データを描画する
    val offset = scrollOffset + displayedDataRange.fromIndex * barInterval
    drawData(
        data = displayedData,
        dataLabelLayoutResults = dataLabelLayoutResults,
        dataValueLayoutResults = dataValueLayoutResults,
        yAxisAttributes = yAxisAttributes,
        plotArea = plotArea,
        dataLabelArea = dataLabelArea,
        dataValueArea = dataValueArea,
        offset = offset,
        attributes = attributes
    )

これで大量のデータも問題無く表示できるようになりました。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4