使用 Jetpack Compose 实现可复用的半圆形仪表盘组件【适用于体重计、温度计等场景】
在 Android 开发中,自定义 UI 组件常常是提升用户体验的关键。本文将介绍如何利用 Jetpack Compose 构建一个美观且高度可定制的半圆形仪表盘(Gauge),适用于体重监测、体温显示、健康数据反馈等多种应用场景。
效果预览如下:

开发环境配置
确保项目已启用 Compose 支持,并正确设置 Kotlin 与 Java 兼容版本:
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
}
核心实现:Compose 自定义绘制
通过 Canvas 组件进行矢量绘图,结合状态驱动动画,实现动态更新的仪表指针和渐变弧形进度条。
@Composable
fun CircularGauge(
value: Float,
maxValue: Float = 100f,
modifier: Modifier = Modifier
) {
var centerX by remember { mutableFloatStateOf(0f) }
var centerY by remember { mutableFloatStateOf(0f) }
var gaugeRadius by remember { mutableFloatStateOf(0f) }
// 计算当前占比对应的角度(从左到右扫过180度)
val sweepAngle by animateFloatAsState(
targetValue = (value / maxValue) * 180f,
label = "gaugeAngle"
)
Box(modifier = modifier) {
Canvas(modifier = Modifier.matchParentSize()) {
// 初始化布局参数
centerX = size.width / 2f
centerY = size.height
gaugeRadius = min(size.width, size.height * 2) / 2f * 0.9f
// 背景弧线(灰色)
drawArc(
color = Color(0xFFE1E6EA),
startAngle = 180f,
sweepAngle = 180f,
useCenter = false,
topLeft = Offset(centerX - gaugeRadius, centerY - gaugeRadius),
size = Size(gaugeRadius * 2, gaugeRadius * 2),
style = Stroke(30f, cap = StrokeCap.Round)
)
// 渐变色进度弧
val gradientBrush = Brush.linearGradient(
colors = listOf(
Color(0xFF7832FD), // 紫
Color(0xFFF293FA), // 粉
),
start = Offset(-gaugeRadius, 0f),
end = Offset(gaugeRadius, 0f)
)
drawArc(
brush = gradientBrush,
startAngle = 180f,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(centerX - gaugeRadius, centerY - gaugeRadius),
size = Size(gaugeRadius * 2, gaugeRadius * 2),
style = Stroke(30f, cap = StrokeCap.Round)
)
// 刻度线绘制:每单位一个刻度,共maxValue+1个
for (tick in 0..maxValue.toInt()) {
val angle = 180f - (tick.toFloat() / maxValue) * 180f
val radians = Math.toRadians(angle.toDouble())
// 区分主刻度、次刻度长度
val length = if (tick % 10 == 0) 20f else if (tick % 5 == 0) 15f else 8f
val strokeWidth = if (tick % 10 == 0) 2.5f else if (tick % 5 == 0) 1.8f else 1f
val innerR = gaugeRadius - 40f
val outerR = innerR - length
val x1 = centerX + innerR * cos(radians).toFloat()
val y1 = centerY - innerR * sin(radians).toFloat()
val x2 = centerX + outerR * cos(radians).toFloat()
val y2 = centerY - outerR * sin(radians).toFloat()
drawLine(
color = Color(0xFFFBCEFF),
start = Offset(x1, y1),
end = Offset(x2, y2),
strokeWidth = strokeWidth
)
// 主刻度旁添加数字标签
if (tick % 10 == 0) {
val textRadius = gaugeRadius - 70f
val textX = centerX + textRadius * cos(radians).toFloat()
val textY = centerY - textRadius * sin(radians).toFloat()
rotate(-angle + 180, pivot = Offset(textX, textY)) {
drawContext.canvas.nativeCanvas.drawText(
"${tick * 2}",
textX,
textY,
android.graphics.Paint().apply {
textAlign = android.graphics.Paint.Align.CENTER
textSize = 12.sp.toPx()
color = android.graphics.Color.parseColor("#FFFBCEFF")
isAntiAlias = true
}
)
}
}
}
// 指针末端的小圆点
val pointerEndAngle = Math.toRadians((180 - sweepAngle).toDouble())
val endX = centerX + gaugeRadius * cos(pointerEndAngle).toFloat()
val endY = centerY - gaugeRadius * sin(pointerEndAngle).toFloat()
drawCircle(
color = Color(0xFFFBCEFF),
radius = 15f,
center = Offset(endX, endY)
)
// 菱形指针主体
val pointerLength = gaugeRadius * 0.8f
val headX = centerX + pointerLength * cos(pointerEndAngle).toFloat()
val headY = centerY - pointerLength * sin(pointerEndAngle).toFloat()
val normalAngle = pointerEndAngle + Math.PI / 2
val halfWidth = 17.5f
val perpCos = cos(normalAngle).toFloat()
val perpSin = sin(normalAngle).toFloat()
val midRatio = 0.4f
val midX = centerX + pointerLength * midRatio * cos(pointerEndAngle).toFloat()
val midY = centerY - pointerLength * midRatio * sin(pointerEndAngle).toFloat()
val leftWingX = midX + halfWidth * perpCos
val leftWingY = midY - halfWidth * perpSin
val rightWingX = midX - halfWidth * perpCos
val rightWingY = midY + halfWidth * perpSin
val path = Path().apply {
moveTo(headX, headY)
lineTo(leftWingX, leftWingY)
lineTo(centerX, centerY)
lineTo(rightWingX, rightWingY)
close()
}
// 填充指针
drawPath(path, color = Color(0xFFFEE7AC))
// 描边
drawPath(path, color = Color(0xFFFEE7AC), style = Stroke(2f))
// 中心底座圆环
drawCircle(
color = Color(0xFFFBCEFF),
radius = 40f,
center = Offset(centerX, centerY)
)
}
}
}
预览与使用示例
借助 @Preview 注解快速查看不同数据状态下的视觉表现:
@Preview(showBackground = true)
@Composable
private fun GaugePreview() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
CircularGauge(value = 0f, modifier = Modifier.height(200.dp))
Spacer(Modifier.height(16.dp))
CircularGauge(value = 50f, modifier = Modifier.height(200.dp))
Spacer(Modifier.height(16.dp))
CircularGauge(value = 100f, modifier = Modifier.height(200.dp))
}
}
若采用 MVVM 架构,可通过 LiveData 或 StateFlow 接入 ViewModel 数据流:
// 在 Composable 中观察 ViewModel 数据
val currentValue by viewModel.currentReading.observeAsState(0f)
CircularGauge(value = currentValue)
该组件支持灵活定制:颜色、尺寸、刻度密度、数值范围、字体样式等均可外部传参控制,便于集成进各类健康管理类 App。