前言
当前时间节点下,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
}
}