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

## はじめに

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

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

Bar Chart は比較的複雑な Component ですので、 [Canvas](https://developer.android.com/reference/kotlin/androidx/compose/foundation/package-summary#Canvas(androidx.compose.ui.Modifier,kotlin.Function1)) を用いて作成することとします。

Android Studio のプロジェクト全体のソースコードを [こちら](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose) に置いておきます。

## x, y 軸を描画する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/15837e91/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [BarChartAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/15837e91/app/src/main/java/net/kino2718/barchart/BarChartAttributes.kt)

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

今回作成する Bar Chart を描画する BarChart Composable 関数の大まかな構成としては一番外側に [BoxWithConstraints](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary#BoxWithConstraints(androidx.compose.ui.Modifier,androidx.compose.ui.Alignment,kotlin.Boolean,kotlin.Function1)) を置き、その中に Canvas を入れています。

BoxWithConstraints は描画領域のサイズを取得するために使用しています。 Canvas に渡す引数の onDraw 関数でもサイズを取得することはできますが、onDraw 関数は Composable ではないためその中では [remember](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#remember(kotlin.Function0)) 関数などの Composable 関数が使えません。そのため Canvas の外側で取得した方が色々と都合が良いためこのような構成にしています。

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

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


```kotlin
@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 は棒グラフの属性をまとめたクラスです。現在は軸の色のみを保持しています。


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

drawAxis 関数は DrawScope をレシーバーとする拡張関数です。このため DrawScope クラスの関数などを呼び出すことができます。ここでは [DrawScope.drawLine](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawLine(androidx.compose.ui.graphics.Color,androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Offset,kotlin.Float,androidx.compose.ui.graphics.StrokeCap,androidx.compose.ui.graphics.PathEffect,kotlin.Float,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode)) を使用して x, y軸を描画しています。

```kotlin
// 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)
    )
}
```

結果を確認してみます。

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/78749f86-49bc-1235-b5c7-b995803e3452.png" width="400" height="400" style="background-color:#FFFFFF;">

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

## データを描画する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/187c8653/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [BarChartAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/187c8653/app/src/main/java/net/kino2718/barchart/BarChartAttributes.kt)
-   [Datum.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/187c8653/app/src/main/java/net/kino2718/barchart/Datum.kt)

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

```kotlin
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 の色となります。

```kotlin
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` 関数で求めています。

```kotlin
@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](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/187c8653/app/src/main/java/net/kino2718/barchart/YAxisAttributes.kt) オブジェクトを返す関数で、これは以下の様に定義されています。今のところは y軸の範囲を表す `minValue`, `maxValue` プロパティのみを持ちます。

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

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


```kotlin
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 を描画します。

```kotlin
@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](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawRect(androidx.compose.ui.graphics.Color,androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Size,kotlin.Float,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode)) 関数で Bar を描画します。また描画領域から Bar がはみ出ないように [clipRect](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#(androidx.compose.ui.graphics.drawscope.DrawScope).clipRect(kotlin.Float,kotlin.Float,kotlin.Float,kotlin.Float,androidx.compose.ui.graphics.ClipOp,kotlin.Function1)) 関数を用いて制限しています。

```kotlin
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)
            )
        }
    }
}
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/2bef63af-95d3-3301-ae1c-bc1c72b02580.png" width="400" height="400" style="background-color:#FFFFFF;">

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/b63a0929-eb07-5ce6-7b3b-bd670149a05e.png" width="400" height="400" style="background-color:#FFFFFF;">

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

## データラベルを描画する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/77695187/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [BarChartAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/77695187/app/src/main/java/net/kino2718/barchart/BarChartAttributes.kt)

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

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

データラベルを描画するにまず文字列のサイズを計測します。Jetpack Compose はその目的のために [TextMeasurer](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextMeasurer) クラスを用意しています。オブジェクトの作成には [rememberTextMeasurer](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/package-summary#rememberTextMeasurer(kotlin.Int)) 関数を使用します。そして `measureDataLabel` 関数でデータラベルを計測します。この計測結果は後ほど文字列を描画する際に使用します。またラベルの最大の高さを取得してこの後ラベルの描画領域を確保するために使用します。

```kotlin
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](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextMeasurer#measure(androidx.compose.ui.text.AnnotatedString,androidx.compose.ui.text.TextStyle,androidx.compose.ui.text.style.TextOverflow,kotlin.Boolean,kotlin.Int,kotlin.collections.List,androidx.compose.ui.unit.Constraints,androidx.compose.ui.unit.LayoutDirection,androidx.compose.ui.unit.Density,androidx.compose.ui.text.font.FontFamily.Resolver,kotlin.Boolean)) 関数にラベルの文字列を [AnnotatedString](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString) 化したものと、文字の色、フォントのサイズを渡してラベル文字列を計測します。計測の結果は [TextLayoutResult](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextLayoutResult) として返されます。ここでは全てのラベルを計測し TextLayoutResult のリストとして結果を返しています。

```kotlin
@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` として設定します。

```kotlin
// 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](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#(androidx.compose.ui.graphics.drawscope.DrawScope).drawText(androidx.compose.ui.text.TextLayoutResult,androidx.compose.ui.graphics.Color,androidx.compose.ui.geometry.Offset,kotlin.Float,androidx.compose.ui.graphics.Shadow,androidx.compose.ui.text.style.TextDecoration,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.BlendMode)) に渡して描画します。 文字の色は文字列の計測時に TextMeasurer.measure 関数に渡しているのでここでは指定していません。

```kotlin
@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 に見つからなかったので自作しています。

```kotlin
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)
    )
}
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/b654c21d-49f4-4610-f708-e3096c1b7da0.png" width="400" height="400" style="background-color:#FFFFFF;">

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/d38330e6-dddf-e673-6331-8c55a49501c1.png" width="400" height="400" style="background-color:#FFFFFF;">

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

## グリッドを描画する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/9e324920/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [BarChartAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/9e324920/app/src/main/java/net/kino2718/barchart/BarChartAttributes.kt)
-   [YAxisAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/9e324920/app/src/main/java/net/kino2718/barchart/YAxisAttributes.kt)

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

```kotlin
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](https://developer.android.com/reference/java/text/DecimalFormat) のパターン文字列を `gridValueFormatPattern` プロパティに渡します。

```kotlin
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` 関数によってグリッド値の文字列のサイズを計測します。またグリッド値の文字列の最大の幅を取得してこの後グリッド値の描画領域を確保するために使用します。

```kotlin
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 のリストとして結果を返しています。

```kotlin
@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` を設定します。

```kotlin
// grid値の文字列とy軸の間のpadding
private val PADDING = 8.dp
```

```kotlin
// 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 で描画しています。

```kotlin
@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)
        )
    }
}
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/0389bbfc-7ed3-303a-1e04-e6cc1ab08c70.png" width="400" height="400" style="background-color:#FFFFFF;">

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/6e37b6d8-49f4-bd61-0055-2021b4e6a302.png" width="400" height="400" style="background-color:#FFFFFF;">

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

```kotlin
@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円"),
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/d4fb6020-f4eb-57ca-b4ab-cd36bf48f26c.png" width="400" height="400" style="background-color:#FFFFFF;">

## データ値を描画する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/44c3887f/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [BarChartAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/44c3887f/app/src/main/java/net/kino2718/barchart/BarChartAttributes.kt)

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

```kotlin
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` 関数によってデータ値の文字列のサイズを計測します。またデータ値の文字列の最大の高さを取得してこの後データ値の描画領域を確保するために使用します。

```kotlin
// データ値を表す文字列を計測する
val dataValueLayoutResults = measureDataValue(
        data = data, attributes = attributes, textMeasurer = textMeasurer)
// データ値を表す文字列の最大の高さを取得する
val maxDataValueHeight = dataValueLayoutResults.maxOfOrNull {
    it.size.height
} ?: 0
```
`measureDataValue` 関数でもこれまでと同様に文字列を TextMeasurer.measure 関数で計測し、その結果を TextLayoutResult として取得します。ここでは全てのデータ値を表す文字列を計測し TextLayoutResult のリストとして結果を返します。

```kotlin
@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軸の描画領域そのままを渡します。

```kotlin
// データの描画エリアを設定する
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 関数に渡しているのでここでは指定していません。

```kotlin
@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)
            )
        }
    }
}
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/b5f16744-cdcd-0df2-9f8a-e77dd95a5423.png" width="400" height="400" style="background-color:#FFFFFF;">

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/2bcb903a-6e81-4f2c-bd13-b81d68736798.png" width="400" height="400" style="background-color:#FFFFFF;">

## スクロール機能を実装

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/39086cae/app/src/main/java/net/kino2718/barchart/BarChart.kt)

データが多く全てのデータを表示しきれない場合はスクロール機能があると良いでしょう。スクロール機能を実装するには [Modifier.scrollable](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).scrollable(androidx.compose.foundation.gestures.ScrollableState,androidx.compose.foundation.gestures.Orientation,kotlin.Boolean,kotlin.Boolean,androidx.compose.foundation.gestures.FlingBehavior,androidx.compose.foundation.interaction.MutableInteractionSource)) 関数を使うと便利です。[こちら](https://developer.android.com/jetpack/compose/gestures?hl=ja#scrollable-modifier) に簡単な説明があります。これを Canvas に渡す Modifier に追加します。orientation 引数は [Orientation](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/Orientation) 型のオブジェクトを渡しスクロール方向を指定します。ここでは Orientation.Horizontal を渡し水平方向を指定しています。また state  引数には [ScrollableState](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/ScrollableState) 型のオブジェクトを渡します。

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

 このオブジェクトは [rememberScrollableState](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/package-summary#rememberScrollableState(kotlin.Function1)) 関数で作成します。

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

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

```kotlin
// 全データの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 型の変数にして、この値が変わったら再コンポーズするようにします。

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

またスクロールが端に達したらスクロールを停止させるために [ScrollableState.stopScroll](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/ScrollableState#(androidx.compose.foundation.gestures.ScrollableState).stopScroll(androidx.compose.foundation.MutatePriority)) 関数を使用しています。この関数は suspend 関数ですので [LaunchedEffect](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#LaunchedEffect(kotlin.Any,kotlin.coroutines.SuspendFunction1)) で囲っています。

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

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

```kotlin
@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(
            // 省略 ...
            )
            // 省略 ...
        }
    }
}
```

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

```kotlin
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
            )
            // 省略 ...

        }
    }
}
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/8d61f465-5a8e-8a62-fdee-42731985b51a.gif" width="400" style="background-color:#FFFFFF;">

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

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/83a072bd/app/src/main/java/net/kino2718/barchart/BarChart.kt)


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

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

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

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

```kotlin
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)
}
```

```kotlin
// 描画領域のサイズ
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` を用いて表示される範囲のデータを元のデータから切り出します。

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

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


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

// 省略 ...

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

結果を確認してみます。

```kotlin
@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)
    )
}

```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/af47949d-394c-cd6b-8aab-bda4126d451b.gif" width="400">

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

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/8da300c9/app/src/main/java/net/kino2718/barchart/BarChart.kt)
-   [YAxisAttributes.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/8da300c9/app/src/main/java/net/kino2718/barchart/YAxisAttributes.kt)

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

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

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

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

```kotlin
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 オブジェクトとして計算します。

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

そしてその値を初期値として [Animatable](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable) 型の変数に変換します。typeConverter 引数は [TwoWayConverter](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/TwoWayConverter) 型で、同名の [TwoWayConverter](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/package-summary#TwoWayConverter(kotlin.Function1,kotlin.Function1)) 関数を用いて作成します。これはオブジェクトをベクトル化し、またその逆にベクトル化した値を元のオブジェクトに戻す役目をします。ここでは初期値として与えている YAxisRange がアニメーション化するプロパティを 2つ持っていますので、2次元のベクトルに変換しています。具体的には TwoWayConverter のコンストラクタの convertToVector 引数に渡すラムダ関数で YAxisRange オブジェクトを [AnimationVector2D](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/AnimationVector2D) オブジェクトに変換しています。また convertFromVector に渡すラムダ関数で AnimationVector2D オブジェクトから YAxisRange オブジェクトに戻しています。

```kotlin
// y軸の範囲をアニメーション化する
val animatedYAxisRange = remember {
    Animatable(
        initialValue = yAxisRange,
        typeConverter = TwoWayConverter(
            convertToVector = {
                AnimationVector2D(it.minValue, it.maxValue)
            }, convertFromVector = {
                YAxisRange(it.v1, it.v2)
            })
    )
}
```

```kotlin
fun <T : Any?, V : AnimationVector> TwoWayConverter(
    convertToVector: (T) -> V,
    convertFromVector: (V) -> T
): TwoWayConverter<T, V>
```

そしてスクロールが終了したらアニメーションを開始するために [Animatable.animateTo](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable#animateTo(kotlin.Any,androidx.compose.animation.core.AnimationSpec,kotlin.Any,kotlin.Function1)) 関数を使用しています。この関数は suspend 関数ですので LaunchedEffect で囲っています。

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

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

```kotlin
val yAxisAttributes = makeYAxisAttributes(yAxisRange = animatedYAxisRange.value)
```

結果を確認してみます。

```kotlin
@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)
    )
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/3a75a6c3-e9b4-1d1e-920d-a0722757b54c.gif" width="400">

## 処理を効率化する

主なファイル

-   [BarChart.kt](https://github.com/kino2718/Bar-Chart-Component-for-Jetpack-Compose/blob/0fdc80ea/app/src/main/java/net/kino2718/barchart/BarChart.kt)

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

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

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

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

```kotlin
// データラベルを計測する
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
    )
```

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179444/9828afac-5176-cb43-5ec5-8bbc63075468.gif" width="400">