0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Jetpack Compose の Canvas を使って棒グラフを書く

0
Posted at

はじめに

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))                                                                                     
        }
    }                                                                                                                                                     
}                                                                                   
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?