在绝大多数的金融、健康或数据统计类 App 中,折线图(Line Chart)都是必不可少的组件。面对这种需求,很多开发者会选择引入庞大且沉重的第三方图表库(如 MPAndroidChart)。但在 Jetpack Compose 时代,得益于强大的 Canvas API,手写一个满足绝大多数定制需求的高颜值、带丝滑展开动画的图表,其实只需要不到两百行代码!
本文将通过 Compose 的 Canvas API 实现 AnimatedLineChart组件,揭开"平滑曲线"、"底部渐变投影"以及"动态展开动画"背后的数学和绘制魔法。

1. 如何把数据画到屏幕上?
假设我们有一组数据 [120f, 340f, 210f, 560f],我们的第一个挑战是:如何将这些抽象的数字,映射为屏幕上的像素坐标 (X, Y)?
- X 轴映射 :非常简单,我们只需要计算出两个点之间的间距 (
spacingX = width / (data.size - 1)),然后通过索引乘以间距即可。 - Y 轴映射:需要注意两点:
-
- Android 屏幕坐标系的原点 (0,0) 在左上角,Y 轴向下是正方向。而折线图中,数值越大,点越应该靠上(Y 坐标越小)。
- 我们不能把图表画得太满,要留出上下内边距 (
paddingTop,paddingBottom) 供坐标文字使用。
坐标转换的算法如下:
Kotlin
// 找到数据的极值,并稍微拉伸一下,让最高点不要顶破天花板
val maxValue = data.maxOrNull() ?: 0f
val minValue = data.minOrNull() ?: 0f
val range = maxValue - minValue
data.forEachIndexed { index, value ->
// 计算 X 坐标
val x = paddingLeft + index * spacingX
// 计算归一化比例 (0.0 ~ 1.0)
val normalizedY = (value - minValue) / range
// 反转 Y 坐标 (1f - normalizedY),映射到实际像素高度
val y = paddingTop + (1f - normalizedY) * chartHeight
points.add(Offset(x, y))
}
2. 曲线平滑魔法:告别僵硬的折角
最基础的折线图是用直线连接的:path.lineTo(x, y)。但如果你想要那种优雅的波浪形平滑曲线,就需要用到三次贝塞尔曲线 ( cubicTo)。
贝塞尔曲线除了起点和终点,还需要两个控制点 (Control Points) 来决定曲线的弯曲方向。最简单的平滑算法是:
- 控制点 1 的 X 坐标取前一个点和当前点的中点,Y 坐标和前一个点一样。
- 控制点 2 的 X 坐标也取中点,Y 坐标和当前点一样。
Kotlin
val previousPoint = points[index - 1]
// X 轴中点
val midX = previousPoint.x + (x - previousPoint.x) / 2f
// 绘制平滑曲线
linePath.cubicTo(
x1 = midX, y1 = previousPoint.y, // 控制点 1
x2 = midX, y2 = y, // 控制点 2
x3 = x, y3 = y // 目标终点
)
仅仅把 lineTo 换成这几行代码,你的图表瞬间就拥有了德芙般的丝滑感!
3. 视觉进阶:底部渐变色投影
纯粹的一根线显得单薄,我们通常会在折线下方填充半透明的渐变色。
做法是同时维护两条 Path:
linePath:只用来画线,不闭合。fillPath:用来画背景填充。它跟着折线一起走,但在到达最后一个点后,它会垂直画到图表底部,然后再连回起点,形成一个闭合的多边形。
Kotlin
// 画线
drawPath(
path = linePath,
color = lineColor,
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
)
// 画渐变背景
drawPath(
path = fillPath, // 这个 Path 是闭合到坐标轴底部的
brush = Brush.verticalGradient( // 垂直渐变刷子
colors = listOf(lineColor.copy(alpha = 0.4f), Color.Transparent),
startY = paddingTop,
endY = chartBottom
)
)
4. 进阶体验:让折线从左向右"生长"
这是整个组件最惊艳、但实现最简单的部分!
你可能会想:是不是要用动画去实时截取 Path 的某一段?或者逐帧计算每一段的曲线?
都不需要!Compose 给我们提供了一个极其廉价且强大的"蒙版"工具: clipRect(矩形裁剪)。
原理:
我们让整条折线和渐变背景完整地画出来,但在画之前,我们在外面套一个"遮罩层"。让遮罩层的右边界随着动画从 0 逐渐增加到 图表总宽度。
Kotlin
// 1. 定义 0f -> 1f 的动画状态
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(data) {
animationProgress.animateTo(1f, tween(1500))
}
// 2. 在 Canvas 中使用 clipRect 进行"拉幕"
clipRect(
left = paddingLeft,
top = 0f,
right = paddingLeft + chartWidth * animationProgress.value, // 右边界动态扩张
bottom = height
) {
// 这里面的所有绘制(折线、点、渐变),超出 clipRect 的部分都不会显示
drawPath(fillPath, ...)
drawPath(linePath, ...)
}
这种做法的性能极高,因为 Path 的数学计算在每一帧里是一模一样的(只会执行一次计算然后被缓存),唯一改变的仅仅是显卡渲染时的可见区域(Clip Region)。
5. 处理文字与网格的痛点
在自定义图表时,往往坐标轴的文字和网格线(Grid Lines)是不参与"从左向右展开动画"的,它们应该一开始就作为"骨架"显示在那里。
所以在代码中,我们把网格线和文本绘制放在了 clipRect的外部。
另外,Compose 旧版本的 DrawScope 对直接绘制文字的支持比较弱,我们通过 drawContext.canvas.nativeCanvas 绕过了这层限制,直接调用了底层 Android 原生 Canvas 的 drawText,并且使用了原生 Paint 开启了抗锯齿(isAntiAlias = true),确保文字边缘清晰锐利。
6. 完整代码
Kotlin
package com.caojing.payment.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* 带动态展开动画的平滑折线图组件(包含 XY 轴刻度与虚线网格)
*
* @param data 纵坐标数据集合
* @param xAxisLabels 横坐标文本标签集合(与 data 数量一致或较少)
* @param modifier 样式修饰符
* @param lineColor 折线的颜色
* @param animationDuration 展开动画时长(毫秒)
* @param showGrid 是否显示背景虚线网格
* @param yAxisLabelCount Y轴刻度的数量
*/
@Composable
fun AnimatedLineChart(
data: List<Float>,
xAxisLabels: List<String> = emptyList(),
modifier: Modifier = Modifier,
lineColor: Color = Color(0xFF2196F3),
animationDuration: Int = 1500,
showGrid: Boolean = true,
yAxisLabelCount: Int = 5
) {
if (data.isEmpty()) return
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(data) {
animationProgress.snapTo(0f)
animationProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = animationDuration,
easing = FastOutSlowInEasing
)
)
}
// 预先准备好原生 Paint 用于绘制文字(兼容低版本 Compose)
val yAxisTextPaint = remember {
android.graphics.Paint().apply {
color = android.graphics.Color.GRAY
textSize = 32f
textAlign = android.graphics.Paint.Align.RIGHT
isAntiAlias = true
}
}
val xAxisTextPaint = remember {
android.graphics.Paint().apply {
color = android.graphics.Color.GRAY
textSize = 32f
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
}
}
Canvas(modifier = modifier) {
val width = size.width
val height = size.height
// 1. 定义图表绘图区的内边距(留出空间画文字)
val paddingLeft = 40.dp.toPx()
val paddingBottom = 30.dp.toPx()
val paddingTop = 20.dp.toPx()
val paddingRight = 16.dp.toPx()
// 实际图表可绘制的宽高
val chartWidth = width - paddingLeft - paddingRight
val chartHeight = height - paddingTop - paddingBottom
val chartBottom = height - paddingBottom
// 计算 Y 轴的极值
val rawMax = data.maxOrNull() ?: 0f
val rawMin = data.minOrNull() ?: 0f
// 为了图表美观,我们稍微拉伸一下 Y 轴最大值,让最高点不要顶到天花板
val maxValue = if (rawMax == rawMin) rawMax + 10f else rawMax + (rawMax - rawMin) * 0.1f
val minValue = if (rawMax == rawMin) 0f else rawMin - (rawMax - rawMin) * 0.1f
val range = maxValue - minValue
// 计算 X 轴的间距
val spacingX = if (data.size > 1) chartWidth / (data.size - 1) else chartWidth
// 2. 绘制 Y 轴刻度与虚线网格
for (i in 0 until yAxisLabelCount) {
val fraction = i.toFloat() / (yAxisLabelCount - 1)
val y = paddingTop + chartHeight * (1f - fraction)
val value = minValue + range * fraction
// 画水平虚线
if (showGrid) {
drawLine(
color = Color.LightGray.copy(alpha = 0.5f),
start = Offset(paddingLeft, y),
end = Offset(width - paddingRight, y),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) // 10px 实线,10px 空白
)
}
// 画 Y 轴文字 (向左偏移 8dp 避免贴在网上)
drawContext.canvas.nativeCanvas.drawText(
value.roundToInt().toString(),
paddingLeft - 8.dp.toPx(),
y + 12f, // 稍微向下偏移以对齐基线
yAxisTextPaint
)
}
// 3. 绘制 X 轴底部文字
xAxisLabels.forEachIndexed { index, label ->
if (index < data.size) {
val x = paddingLeft + index * spacingX
drawContext.canvas.nativeCanvas.drawText(
label,
x,
height - 10f, // 靠近底部
xAxisTextPaint
)
}
}
// 4. 计算折线图的点位坐标
val linePath = Path()
val fillPath = Path()
val points = mutableListOf<Offset>()
data.forEachIndexed { index, value ->
val x = paddingLeft + index * spacingX
val normalizedY = (value - minValue) / range
val y = paddingTop + (1f - normalizedY) * chartHeight
points.add(Offset(x, y))
if (index == 0) {
linePath.moveTo(x, y)
fillPath.moveTo(x, chartBottom) // 填充起点在底部
fillPath.lineTo(x, y)
} else {
val previousPoint = points[index - 1]
val controlX1 = previousPoint.x + (x - previousPoint.x) / 2f
val controlY1 = previousPoint.y
val controlX2 = previousPoint.x + (x - previousPoint.x) / 2f
val controlY2 = y
linePath.cubicTo(controlX1, controlY1, controlX2, controlY2, x, y)
fillPath.cubicTo(controlX1, controlY1, controlX2, controlY2, x, y)
}
}
if (points.isNotEmpty()) {
fillPath.lineTo(points.last().x, chartBottom) // 连回到底部
fillPath.lineTo(points.first().x, chartBottom) // 连回到起点
fillPath.close()
}
// 5. 使用 clipRect 进行动画裁剪(只裁剪图表区,不影响 XY 轴文字)
clipRect(
left = paddingLeft,
top = 0f,
right = paddingLeft + chartWidth * animationProgress.value, // 根据动画进度动态右边界
bottom = height
) {
// 画底部渐变填充
drawPath(
path = fillPath,
brush = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.4f), Color.Transparent),
startY = paddingTop,
endY = chartBottom
)
)
// 画平滑折线
drawPath(
path = linePath,
color = lineColor,
style = Stroke(
width = 3.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
// 画数据节点的小圆圈
points.forEach { point ->
drawCircle(color = Color.White, radius = 5.dp.toPx(), center = point)
drawCircle(color = lineColor, radius = 3.dp.toPx(), center = point)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun AnimatedLineChartPreview() {
// 模拟一周的流水数据
var chartData by remember { mutableStateOf(listOf(120f, 340f, 210f, 560f, 430f, 890f, 650f)) }
val daysOfWeek = listOf("一", "二", "三", "四", "五", "六", "日")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("近七日流水趋势", modifier = Modifier.padding(bottom = 16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF9F9F9), RoundedCornerShape(16.dp))
.padding(vertical = 16.dp, horizontal = 8.dp)
) {
AnimatedLineChart(
data = chartData,
xAxisLabels = daysOfWeek,
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
lineColor = Color(0xFF6200EA),
showGrid = false
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
// 随机刷新数据,观察动画和坐标轴的自适应
chartData = List(7) { (100..1000).random().toFloat() }
}) {
Text("刷新数据")
}
}
}
总结
利用 Jetpack Compose 的 Canvas:
- 数据映射
- 贝塞尔曲线 (
cubicTo实现平滑) - 闭合路径与渐变 (
verticalGradient) - 裁剪动画 (
clipRect+Animatable的拉幕魔法)