Jetpack Compose 实战:实现一个动态平滑折线图

在绝大多数的金融、健康或数据统计类 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 轴映射:需要注意两点:
    1. Android 屏幕坐标系的原点 (0,0) 在左上角,Y 轴向下是正方向。而折线图中,数值越大,点越应该靠上(Y 坐标越小)。
    2. 我们不能把图表画得太满,要留出上下内边距 (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

  1. linePath:只用来画线,不闭合。
  2. 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:

  1. 数据映射
  2. 贝塞尔曲线 (cubicTo 实现平滑)
  3. 闭合路径与渐变 (verticalGradient)
  4. 裁剪动画 (clipRect + Animatable 的拉幕魔法)
相关推荐
李艺为5 小时前
Fake Device Test作假屏幕分辨率分析
android·java
zh_xuan6 小时前
github远程library仓库升级
android·github
峥嵘life6 小时前
Android蓝牙停用绝对音量原理
android
Highcharts.js7 小时前
线形比赛积分增长或竞赛图|Highcharts企业图表代码示列
开发语言·前端·javascript·折线图·highcharts·竞赛图
czlczl200209257 小时前
IN和BETWEEN在索引效能的区别
android·adb
Volunteer Technology7 小时前
ES高级搜索功能
android·大数据·elasticsearch
北京自在科技7 小时前
Find Hub App 小更新
android·ios·安卓·findmy·airtag
stevenzqzq8 小时前
compose中Modifier.padding 与 contentPadding 区别
compose
lbb 小魔仙8 小时前
2026远程办公软件夏季深度横测:ToDesk、向日葵、网易UU远程全面对比,远控白皮书
android·服务器·网络协议·tcp/ip·postgresql