はじめに
MPAndroidChart などのライブラリを使用すれば書くことはできますが、Compose との統合がシンプルにできたり、細かいレイアウト制御(Y軸ラベル幅を動的に計算してバーエリアを調整したり)が自由にできるなど、自作するメリットもあります。
週ごとの距離(シアン)とカロリー(グリーン)を積み上げた棒グラフです。Y軸ラベル・破線グリッド・X軸ラベル付きです。
MPAndroidChart などのライブラリは使わず、Compose の Canvasのみで実装します。
実装
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow
// ---- データモデル ----
data class BarEntry(
val xLabel: String,
val distanceKm: Float,
val caloriesKcal: Float,
)
// ---- Y軸スケール ----
fun niceMax(value: Float): Float {
if (value <= 0f) return 2f
val exp = floor(log10(value.toDouble())).toInt()
val mag = 10f.pow(exp)
return when {
value <= mag -> mag
value <= 2f * mag -> 2f * mag
value <= 5f * mag -> 5f * mag
else -> 10f * mag
}.coerceAtLeast(2f)
}
// ---- グラフ本体 ----
private val ColorDistance = Color(0xFF00BCD4)
private val ColorCalories = Color(0xFF4CAF50)
private const val CALORIES_TO_KM_FACTOR = 100f
@Composable
fun StackedBarChart(
entries: List<BarEntry>,
labelColor: Color,
gridColor: Color,
modifier: Modifier = Modifier,
) {
if (entries.isEmpty()) return
val gridMax = niceMax(
entries.maxOf { it.distanceKm + it.caloriesKcal / CALORIES_TO_KM_FACTOR }
)
val gridMid = gridMax / 2f
val density = LocalDensity.current
val labelTextSizePx = with(density) { 9.sp.toPx() }
// Compose Color → ARGB(nativeCanvas でテキストを描くために必要)
val labelArgb = android.graphics.Color.argb(
(labelColor.alpha * 255).toInt(),
(labelColor.red * 255).toInt(),
(labelColor.green * 255).toInt(),
(labelColor.blue * 255).toInt(),
)
val textPaint = android.graphics.Paint().apply {
color = labelArgb
textSize = labelTextSizePx
isAntiAlias = true
}
// Y軸ラベル幅を事前計測してバーエリアの左マージンを決める
val yLabelMaxPx = android.graphics.Paint()
.apply { textSize = labelTextSizePx }
.measureText("%.0fkm".format(gridMax))
val leftMarginPx = yLabelMaxPx + with(density) { 6.dp.toPx() }
val leftMarginDp = with(density) { leftMarginPx.toDp() }
Column(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxWidth().height(160.dp)) {
val w = size.width
val h = size.height
val barSlotWidth = (w - leftMarginPx) / entries.size.toFloat()
// 外枠
drawRect(
color = gridColor.copy(alpha = 0.4f),
style = Stroke(width = 1.dp.toPx())
)
// 中間グリッド線(破線)
val midY = h * (1f - gridMid / gridMax)
drawLine(
color = gridColor.copy(alpha = 0.3f),
start = Offset(leftMarginPx, midY),
end = Offset(w, midY),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))
)
// Y軸ラベル
drawContext.canvas.nativeCanvas.apply {
drawText("%.0fkm".format(gridMax), 0f, labelTextSizePx, textPaint)
drawText("%.0fkm".format(gridMid), 0f, midY + labelTextSizePx, textPaint)
}
// 積み上げバー
entries.forEachIndexed { index, entry ->
val slotCenterX = leftMarginPx + barSlotWidth * index + barSlotWidth / 2f
val bw = 8.dp.toPx()
val left = slotCenterX - bw / 2f
val distBarH = h * (entry.distanceKm / gridMax).coerceIn(0f, 1f)
val calBarH = h * ((entry.caloriesKcal / CALORIES_TO_KM_FACTOR) / gridMax).coerceIn(0f, 1f)
// 距離バー(下から)
if (distBarH > 0f) {
drawRect(
color = ColorDistance,
topLeft = Offset(left, h - distBarH),
size = Size(bw, distBarH)
)
}
// カロリーバー(距離バーの上に 1dp ギャップを空けて積む)
if (calBarH > 0f) {
val gap = if (distBarH > 0f) 1.dp.toPx() else 0f
drawRect(
color = ColorCalories,
topLeft = Offset(left, h - distBarH - gap - calBarH),
size = Size(bw, calBarH)
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
// X軸ラベル(Canvas 外の Row で均等配置)
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(leftMarginDp))
Row(modifier = Modifier.weight(1f)) {
entries.forEach { entry ->
Text(
text = entry.xLabel,
fontSize = 9.sp,
color = labelColor,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
要点解説
データクラス
data class BarEntry(val xLabel: String, val distanceKm: Float, val caloriesKcal: Float)
Y軸スケールを「キリのいい数字」にします。
目盛りが中途半端にならないよう 1 / 2 / 5 × 10^n に丸めます。
fun niceMax(value: Float): Float {
if (value <= 0f) return 2f
val exp = floor(log10(value.toDouble())).toInt()
val mag = 10f.pow(exp)
return when {
value <= mag -> mag
value <= 2f * mag -> 2f * mag
value <= 5f * mag -> 5f * mag
else -> 10f * mag
}.coerceAtLeast(2f)
}
メモリの数値をリスト化してもっといい感じにできる気がする・・・
Canvas で描く
ポイントは3つです。
① Y軸ラベル幅を事前計測してバーエリアを決める
val yLabelMaxPx = android.graphics.Paint()
.apply { textSize = with(density) { 9.sp.toPx() } }
.measureText("%.0fkm".format(gridMax))
val leftMarginPx = yLabelMaxPx + with(density) { 6.dp.toPx() }
② nativeCanvas でテキストを描く場合は Color を ARGB に変換する
val argb = android.graphics.Color.argb(
(color.alpha * 255).toInt(), (color.red * 255).toInt(),
(color.green * 255).toInt(), (color.blue * 255).toInt()
)
③ バーは下から距離 → 1dp ギャップ → カロリーの順に drawRect
drawRect(color = colorDistance, topLeft = Offset(left, h - distBarH), size = Size(bw, distBarH))
val gap = if (distBarH > 0f) 1.dp.toPx() else 0f
drawRect(color = colorCalories, topLeft = Offset(left, h - distBarH - gap - calBarH), size = Size(bw, calBarH))
X軸ラベルは Row で並べる
Canvas 外に Row + weight(1f) で配置し、leftMarginDp 分 Spacer を置くだけでバーの位置と揃います。
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(leftMarginDp))
Row(modifier = Modifier.weight(1f)) {
entries.forEach { entry ->
Text(text = entry.xLabel, modifier = Modifier.weight(1f))
}
}
}