Android jetpack compose 绘制圆滑渐变色折线图,可点击选择X轴坐标及其相关Y轴值

Android jetpack compose 绘制圆滑渐变色折线图,可点击选择X轴坐标及其相关Y轴值

前言

当前时间节点下,jetpack compose确实太新了,想找个好用的折线图轮子都没找到,只好自己摸索来做了。
在这里插入图片描述

代码

依赖

https://github.com/D10NGYANG/DLDateUtil
https://github.com/D10NGYANG/DLStringUtil
只是拿两个字符串显示转换的方法而已,可以不依赖的

实现

配色代码

/* 主题色 */
// 主题颜色 首选颜色 标题栏颜色
val lightPrimary = Color(0xFFFFFFFF)
// 首选颜色的阴影色
val lightPrimaryVariant = Color(0xFFEFEFEF)
// 显示在首选颜色上的字体或图标颜色
val lightOnPrimary = Color(0xFF333333)
// 主题颜色 次要颜色 按钮颜色
val lightSecondary = Color(0xFF3091F2)
// 次要颜色的阴影色
val lightSecondaryVariant = Color(0xFFCCE6FF)
// 显示在次要颜色上的字体或图标颜色
val lightOnSecondary = Color(0xFFFFFFFF)

/* 字体颜色 */
val textTitle = Color(0xFF333333)
val textBody = Color(0xFF666666)
val textHint = Color(0xFF999999)
val textError = Color(0xFFFF574D)

/* 背景颜色 */
val lightBackground = Color(0xFFFFFFFF)
val inputBackground = Color(0x1F8E8E93)
val inputBorder = Color(0xFFE3E3E3)
val chatBackground = Color(0xFFEDEDED)
val loadingDialogBackground = Color(0x97454545)

/* 线条颜色 */
val colorLine = Color(0xFFEBEEF0)

UI代码

import android.graphics.Path
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material.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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.d10ng.datelib.toDateStr
import com.d10ng.stringlib.toDString
import kotlin.math.ceil
import kotlin.math.roundToInt

/**
 * 横轴为时间戳的折线图
 * @param modifier Modifier
 * @param title String 标题
 * @param data List<Pair<Long, Double>> 输入数据,时间戳和值列表
 * @param selectX Long 选中的时间戳
 * @param xLabelCount Int X轴的标记点数量
 * @param yLabelCount Int Y轴的标记点数量
 * @param onTapX Function1<Pair<Long, Double>, Unit> 点击折线图得到点击位置的数值
 */
@Composable
fun TimeLineChart(
    modifier: Modifier,
    title: String,
    data: List<Pair<Long, Double>>,
    selectX: Long = 0,
    xLabelCount: Int = 6,
    yLabelCount: Int = 5,
    onTapX: (Pair<Long, Double>) -> Unit
) {
    if (data.isEmpty()) return
    // Y轴最大值
    val maxYDouble = remember(data) {
        data.maxOf { it.second }
    }
    // Y轴最小值
    val minYDouble = remember(data) {
        data.minOf { it.second }
    }
    // Y轴最大值的向上取整值
    val maxYInt = remember(maxYDouble) {
        maxYDouble.roundToInt().coerceAtLeast(1)
    }
    // Y轴文本个数
    val countYLabel = remember(maxYInt) {
        maxYInt.coerceAtMost(yLabelCount).coerceAtLeast(2)
    }
    // X轴最大值
    val maxXLong = remember(data) {
        data.maxOf { it.first }
    }
    // X轴最小值
    val minXLong = remember(data) {
        data.minOf { it.first }
    }
    // X轴文本个数
    val countXLabel = remember(maxXLong, minXLong) {
        xLabelCount.coerceAtMost((maxXLong - minXLong).toInt()).coerceAtLeast(2)
    }

    // 计算器
    var mCalculation by remember {
        mutableStateOf<LineChartCalculation?>(null)
    }

    Column (modifier = modifier) {
        Row (verticalAlignment = Alignment.Bottom) {
            // 标题
            Text(text = title, style = text14BoldTitle)
            // 当前X值
            Text(
                text = selectX.toDateStr(),
                style = text12MediumBody,
                modifier = Modifier.padding(start = 8.dp)
            )
            // 当前Y值
            Text(
                text = "${(getYValueFromX(data, selectX)?: 0.0).toDString(1)}节",
                style = text12MediumBody,
                modifier = Modifier.padding(start = 16.dp)
            )
        }
        Row (
            modifier = Modifier
                .fillMaxSize()
                .weight(1f)
        ) {
            // Y轴文本
            ChartYLabels(max = maxYInt, count = countYLabel)
            // 图表
            Canvas(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(top = 8.dp, start = 4.dp, bottom = 4.dp, end = 16.dp)
                    .pointerInput(Unit) {
                        detectTapGestures(
                            onTap = { offset ->
                                // 点击反馈
                                mCalculation?.let { calculation ->
                                    val time = calculation.parseX(offset.x)
                                    val yValue = getYValueFromX(data, time)?: return@let
                                    onTapX.invoke(Pair(time, yValue))
                                }
                            }
                        )
                    }
            ) {
                // XY坐标轴上的指示条的长度
                val axisTipsLength = 8.dp.toPx()
                // 可供绘制图表的宽度和高度
                val chartW = size.width - axisTipsLength
                val chartH = size.height - axisTipsLength
                // 绘制X轴
                drawLine(
                    color = colorLine,
                    start = Offset(0f, chartH),
                    end = Offset(size.width, chartH),
                    strokeWidth = 1.dp.toPx(),
                    cap = StrokeCap.Round
                )
                // 绘制X轴上面的指示条
                val spaceX = chartW / (countXLabel - 1)
                for (i in 0 until countXLabel) {
                    val x = spaceX * i + axisTipsLength
                    drawLine(
                        color = colorLine,
                        start = Offset(x, chartH),
                        end = Offset(x, size.height),
                        strokeWidth = 1.dp.toPx(),
                        cap = StrokeCap.Round
                    )
                }
                // 绘制Y轴
                drawLine(
                    color = colorLine,
                    start = Offset(axisTipsLength, 0f),
                    end = Offset(axisTipsLength, size.height),
                    strokeWidth = 1.dp.toPx(),
                    cap = StrokeCap.Round
                )
                // 绘制Y轴上面的指示条
                val spaceY = chartH / (countYLabel - 1)
                for (i in 0 until countYLabel) {
                    val y = spaceY * i
                    drawLine(
                        color = colorLine,
                        start = Offset(axisTipsLength, y),
                        end = Offset(0f, y),
                        strokeWidth = 1.dp.toPx(),
                        cap = StrokeCap.Round
                    )
                }
                // 绘制曲线
                // 创建计算器
                val calculation = LineChartCalculation(
                    chartW, axisTipsLength, maxXLong, minXLong,
                    chartH, 0f, maxYDouble, minYDouble
                )
                mCalculation = calculation
                // 创建路径
                val path = Path()
                // 移动到开始点
                var controlX = axisTipsLength
                var controlY = calculation.getY(data[0].second)
                path.moveTo(controlX, controlY)
                // 循环绘制贝塞尔曲线,控制点为上一个点,结束点为上一个点和现在点的中间位置
                data.forEachIndexed { index, item ->
                    if (index == 0) return@forEachIndexed
                    val currentX = calculation.getX(item.first)
                    val currentY = calculation.getY(item.second)
                    val endX = (controlX + currentX) / 2
                    val endY = (controlY + currentY) / 2
                    path.quadTo(controlX, controlY, endX, endY)
                    controlX = currentX
                    controlY = currentY
                }
                // 连线到最后一个点
                path.lineTo(size.width, calculation.getY(data[data.size -1].second))
                // 绘制路径
                drawPath(
                    path.asComposePath(),
                    color = lightSecondary,
                    style = Stroke(width = 4f, cap = StrokeCap.Round)
                )
                // 连线到右侧底部
                path.lineTo(size.width, chartH)
                // 连线到左侧底部
                path.lineTo(axisTipsLength, chartH)
                // 闭合路径
                path.close()
                // 绘制曲线下方的渐变色
                drawPath(
                    path.asComposePath(),
                    brush = Brush.verticalGradient(
                        colors = listOf(
                            lightSecondary.copy(alpha = lightSecondary.alpha / 2),
                            Color.Transparent
                        )
                    )
                )
                // 画当前选择线
                val selectXp = calculation.getX(selectX)
                drawLine(
                    color = Color(0xFFFF574D),
                    start = Offset(selectXp, 0f),
                    end = Offset(selectXp, chartH),
                    strokeWidth = 1.dp.toPx(),
                    cap = StrokeCap.Round
                )
            }
        }
        // X轴文本
        ChartXLabels(max = maxXLong, min = minXLong, count = countXLabel)
    }
}

/**
 * 通过X轴数据获取Y轴数据
 * @param data List<Pair<Long, Double>>
 * @param x Long
 * @return Double?
 */
private fun getYValueFromX(data: List<Pair<Long, Double>>, x: Long): Double? {
    // 先抱有侥幸心里尝试下是不是一下子就点到了真实数据
    val selectIndex = data.indexOfFirst { it.first == x }
    if (selectIndex >= 0) {
        // 真就那么巧,牛B
        return data[selectIndex].second
    } else {
        // 没那么幸运,那就得找到一前一后的真实点,然后做虚拟数据了
        val leftIndex = data.indexOfLast { it.first < x }
        // 没有找到前面的数据,说明点击超出图表范围了
        if (leftIndex !in 0 until data.size -1 ) return null
        val leftPoint = data[leftIndex]
        val rightPoint = data[leftIndex + 1]
        // 计算选择的时间在两个点的时间中的百分比
        val per = (x - leftPoint.first).toDouble() /
                (rightPoint.first - leftPoint.first)
        // 计算Y轴数据
        // 返回
        return (rightPoint.second - leftPoint.second) * per + leftPoint.second
    }
}

/**
 * Y轴文本 速度
 * @param modifier Modifier
 * @param max Int 最大值
 * @param min Int 最小值
 * @param count Int 个数
 */
@Composable
fun ChartYLabels(
    modifier: Modifier = Modifier,
    max: Int,
    min: Int = 0,
    count: Int = 5
) {
    Column (
        modifier = modifier
            .fillMaxHeight()
            .wrapContentWidth(),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.End
    ) {
        val realCount = count -1
        val step = ceil((max.toFloat() - min) / realCount).toInt()
        for (i in realCount downTo 0) {
            Text(
                text = "${step * i}",
                style = text10NormalHint
            )
        }
    }
}

/**
 * X轴文本 时间
 * @param modifier Modifier
 * @param max Long
 * @param min Long
 * @param count Int
 */
@Composable
fun ChartXLabels(
    modifier: Modifier = Modifier,
    max: Long,
    min: Long,
    count: Int = 6
) {
    Row (
        modifier = modifier
            .fillMaxWidth()
            .wrapContentHeight(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        val realCount = count -1
        val step = (max - min) / realCount
        val pattern = when(max - min) {
            in 0 until 1000 -> "ss.SSS"
            in 1000 until 60000 -> "mm:ss"
            in 60000 until 3600000 -> "HH:mm:ss"
            in 3600000 until 86400000 -> "HH:mm"
            else -> "MM-dd"
        }
        for (i in 0 .. realCount) {
            Text(
                text = (step * i + min).toDateStr(pattern),
                style = text10NormalHint,
                textAlign = TextAlign.Center
            )
        }
    }
}

@Preview
@Composable
fun LineChart_test() {
    val data = listOf(
        Pair(1632444894000, 0.0),
        Pair(1632444912000, 1.6000000000000001),
        Pair(1632444913000, 0.82999999999999996),
        Pair(1632444918000, 2.7000000000000002),
        Pair(1632444920000, 5.7000000000000002),
        Pair(1632444924000, 13.800000000000001),
        Pair(1632444927000, 18.59),
        Pair(1632444930000, 21.199999999999999),
        Pair(1632444934000, 23.420000000000002),
        Pair(1632444936000, 21.100000000000001),
        Pair(1632444941000, 16.379999999999999),
        Pair(1632444942000, 14.6),
        Pair(1632444948000, 16.5),
        Pair(1632444954000, 18.600000000000001),
        Pair(1632444955000, 18.699999999999999),
        Pair(1632444960000, 12.4),
    )
    TimeLineChart(
        modifier = Modifier
            .height(150.dp)
            .background(lightBackground),
        title = "速度",
        data = data,
        selectX = 1632444936000,
        onTapX = {}
    )
}

/**
 * 图表数据位置计算
 * @property width Float
 * @property widthOffset Float
 * @property maxX Long
 * @property minX Long
 * @property height Float
 * @property heightOffset Float
 * @property maxY Double
 * @property minY Double
 * @constructor
 */
private class LineChartCalculation(
    val width: Float,
    val widthOffset: Float,
    val maxX: Long,
    val minX: Long,
    val height: Float,
    val heightOffset: Float,
    val maxY: Double,
    val minY: Double
) {

    fun getX(value: Long): Float {
        return (width * ((value.toDouble() - minX) / (maxX - minX))).toFloat() + widthOffset
    }

    fun parseX(value: Float): Long {
        return (((value - widthOffset) / width).toDouble() * (maxX - minX)).toLong() + minX
    }

    fun getY(value: Double): Float {
        return height - (height * ((value - minY) / (maxY - minY))).toFloat() + heightOffset
    }
}

完事

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值