先看效果图:
需要用到的知识点:
jetpack compose 绘图部分的api;
少部分高中数学知识。
一、折线图载体
这里折线图的载体,使用的是Card,嵌套一个Canvas,而Canvas正是图形接口的载体:
/**
* @param times 横轴的时间
* @param color 折线图,线的颜色
* @param data 折线图的数据
* @param chartTitle 折线图的标题
*/
//
@Composable
fun LineChart(
times: List<String>,
color: List<Color>,
vararg data: List<PointF>,
chartTitle: String = ""
) {
if (times.isEmpty()) return
Card(
shape = RoundedCornerShape(10),
elevation = 10.dp,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.618f)
.padding(15.dp)
) {
}
}
解释一下上面的代码,这里的折线图是根据我们的业务需求来实现的,其中:
times:横轴上的每个坐标显示的文本,因为我们的业务需求是绘制某变量随时间变化的曲线,所以这里使用的是times作为横轴的变量名。
color:每条曲线的颜色。原则上这个颜色列表的长度应该跟后面的数据数组的长度是一致的,但是在绘制曲线的时候,这里只会根据数据数组的索引在颜色列表中取值,所以颜色列表的长度应当大于或等于数据数组的长度。
data:数据数组,数组中的每条数据对应于折线图上的一条曲线。
chartTitle:折线图上的标题。
当然,一个折线图可定制的内容还有很多很多,这里只是根据业务需求添加的。
Card容器,形状设定为圆角矩形,圆角为10%,15dp的内边距,占满所有的宽度,并且长宽比为1.618(没错这是黄金分割比例,老强迫症了)
二、标题部分
学过Android framework肯定知道,Android material design里面的CardView是派生自FrameLayout的,也就是一个堆叠视图,而compose里面,Card依然是个堆叠视图,也就是说Card是另外一种Box,所以这里我是直接把标题以Text的可组合函数的形式放到界面上的:
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Text(chartTitle)
}
这样就把标题放置在了整个视图的顶部中间。
三、绘制曲线
使用Canvas来作为曲线的容器,尺寸铺满整个Card容器:
Canvas(modifier = Modifier.fillMaxSize()) {
}
因为后面绘制曲线需要用到计算曲线上的坐标点,所以这里需要获取到容器的宽高,
val height = size.height - 16.dp.toPx()
val width = size.width
这里的高度我给减去了16dp的宽度,是给横轴的文字预留的空间。
如果想完整的绘制每条曲线,这里我们需要获取到所有曲线里面的最大值:
var maxValue = data.map { it.maxOf { d -> d.y } }.maxOf { f -> f }
计算每个坐标点,需要根据最大值,当前值,和屏幕的尺寸来计算,所以需要先写两个全局方法:
fun xCoordination(width: Float, x: Float, totalCount: Int): Float {
return width * (x / (totalCount - 1))
}
fun yCoordination(height: Float, y: Float, maxValue: Float): Float {
return height - y * (height / maxValue) * 0.95f
}
两个方法都是用总体的尺寸,乘以当前点在所有数据中的比例,就得到了当前点在容器中的位置。
y坐标后面有个0.95的系数,是为了保证最高的数值也给顶部留下5%的控件,看着和谐一点。
然后遍历数据数组中的每个数据点集合:
data.forEachIndexed first@{ index, it ->
if (it.isEmpty()) return@first
}
曲线的核心api是drawPath,所以首先需要一个Path对象,并且移动到第一个数据点的位置:
val path = Path()
path.moveTo(
xCoordination(width, it[0].x, it.size),
yCoordination(height, it[0].y, maxValue)
)
为了使曲线看上去不那么突兀,圆滑一点,有两种方法:
·使用贝塞尔曲线来绘制
·使用PathEffect.cornerPathEffect()作为绘制的参数
但是这个drawPath的api里面没有pathEffect这个参数,所以这里只能使用二阶贝塞尔曲线来绘制。
二阶贝塞尔曲线每次绘制一段曲线需要一个控制点和终点,这里我把数据点集合中的上一个坐标点作为控制点,而上一段曲线的的控制点和本段的坐标点的中点作为终点:
var controlX = xCoordination(width, it[0].x, it.size)
var controlY = yCoordination(height, it[0].y, maxValue)
it.forEachIndexed second@{ index1, dataPoint ->
if (index1 == 0) return@second
val endX = (controlX + xCoordination(width, dataPoint.x, it.size)) / 2
val endY = (controlY + yCoordination(height, dataPoint.y, maxValue)) / 2
path.quadTo(controlX, controlY, endX, endY)
controlX = xCoordination(width, dataPoint.x, it.size)
controlY = yCoordination(height, dataPoint.y, maxValue)
}
path.lineTo(
xCoordination(width, it[it.size - 1].x, it.size),
yCoordination(height, it[it.size - 1].y, maxValue)
)
使用drawPath将曲线绘制在Canvas上面:
drawPath(
path.asComposePath(),
color = color[index],
style = Stroke(width = 4f, cap = StrokeCap.Round)
)
这里style可选项有两个,第一个就是这里使用的Stroke,只会绘制边线;第二个是Fill,会绘制path的整个封闭区域(当然,如果path并不封闭,我没试过是否会自动闭合曲线并绘制整个区域,也懒得去查)。
到这里曲线就会绘制完成了,不过为了好看,我在曲线下方加上了对应于曲线颜色的渐变色,使用的也是drawPath,但是style使用的是Fill,来绘制整个封闭区域。
所以首先要把Path封闭起来,然后才能绘制:
path.lineTo(width, height)
path.lineTo(
xCoordination(width, it[0].x, it.size),
height
)
path.close()
drawPath(
path.asComposePath(),
brush = Brush.verticalGradient(
colors = listOf(
color[index].copy(alpha = color[index].alpha / 2),
Color.Transparent
)
)
)
上面首先把path划到曲线空间的最右下角,然后在划到曲线空间的最左下角,再调用close(),整个路径就封闭起来了。然后调用drawPath把整个区域的渐变色绘制出来。渐变色使用的是垂直方向的渐变色,最上面是曲线的颜色,然后不透明度砍半,最下面直接是透明色。
四、绘制横坐标
这个地方可以故技重施,使用Text可组合函数把横坐标写到图形的最下面,也就是绘制曲线的时候预留出来的那片空间。
但是之前用xml文件实现Android界面的时候,都知道如果把字体大小设定到12sp以下,Android studio就会始终警告你12sp以下的观感并不好,还挺烦的,Android开发应该都感受过那种被黄色波浪线支配的恐惧。
当然这里如果用Text可组合函数,我没试过把文字大小改的很小,也不确定是否compose这个时候也会有黄色波浪警告。
另一个不使用Text的原因是精度不够,只能大概的对应横轴坐标的位置。
但是很麻烦的是,jetpack compose Canvas的DrawScope并没有提供drawText接口,所以这里就需要用到上一篇里面提到的api:drawIntoCanvas来调用底层Android Framework的画板来绘制文字。
当然,使用底层Android Framework的画板,首先必不可少的就是一个画笔对象了:
//初始化画笔
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
frameworkPaint.run {
isAntiAlias = true
textSize = 8.dp.toPx()
this.color = Color.Black.toArgb()
}
设定为抗锯齿,8dp的字体大小,黑色。
然后确定x轴上字符串的数量以及每个x轴字符串的位置:
val xLabelCount = if (times.size > 8) 8 else times.size
val indexes = (1 until xLabelCount).map { n -> times.size / xLabelCount
* n }
val labels = times.filterIndexed { index, _ -> index in indexes }
.map { t -> t.substring(0 until 5) }
val labelPositions = (1 until xLabelCount).map { n ->
PointF(
size.width / xLabelCount * n,
size.height - 6.dp.toPx()
)
}
这里我设置的是x轴上显示至多七个字符串,太多会显得拥挤,甚至文字都挤到一起去。
然后调用drawIntoCanvas将文字绘制到界面:
drawIntoCanvas {
//将x轴信息绘制到底部
labelPositions.forEachIndexed { index, pointF ->
it.nativeCanvas.drawText(
labels[index],
pointF.x - frameworkPaint.measureText(labels[index]) / 2,
pointF.y,
frameworkPaint
)
}
}
上面绘制文字的过程中,很重要的一步就是把绘制文字的具体坐标减去文字长度的一半,使字符串的中心对齐它所表示的x坐标。也就是pointF.x - frameworkPaint.measureText(labels[index]) / 2这一步。
五、绘制纵坐标
纵坐标就不能像横坐标一样直接暴力均分了,因为均分出来很可能是一串乱七八糟的数字,想要纵坐标的数字比较和谐,就要把纵坐标的数字都对应到相应的整数,只有前一位或者两位可以是除零以外的整数,其他全是零,所以最关键的一点就是,怎么确定纵坐标的数量级和数量,就是科学计数法里面E后面那个数字是多少。
这里我用取对数的方法获取到y最大值以10为底的对数,也就确定了最大值的数量级。
但是光有数量级也是不行的,万一最大值是1开头的,总不能就绘制一个数值,所以需要根据首位的大小来确定数量级的大小和纵坐标的数量:
val yLabels: List<Int>
var power = floor(log(maxValue, 10f)).toInt()
var factor = maxValue / 10.0.pow(power.toDouble())
if (factor < 4) {
factor *= 10
power -= 1
yLabels = (1..7).map {
(factor / 8 * it).roundToInt()
}
} else {
yLabels = (1..factor.toInt()).map {
(factor / (factor.toInt() + 1) * it).roundToInt()
}
}
这里我选择如果最大值的首位小于4,就将首位乘以10,然后数量级减一,这样就有4到9个纵坐标,根据首位的大小而定。
再由确定的y轴要显示的数字,计算出他们具体在视图中的高度:
val yPositions: List<Float> =
yLabels.map {
yCoordination(
height,
it * 10.0.pow(power.toDouble()).toFloat(),
maxValue
)
}
然后就可以在画板中画出这些文字和对应的虚线:
yLabels.forEachIndexed { yLabelIndex, yLabel ->
drawLine(
brush = Brush.horizontalGradient(
listOf(
Color.LightGray,
Color.LightGray
)
),
start = Offset(0f, yPositions[yLabelIndex]),
end = Offset(width, yPositions[yLabelIndex]),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
)
drawIntoCanvas {
it.nativeCanvas.drawText(
if (power >= 0) (yLabel * 10.0.pow(power.toDouble())
.toInt()).toString() else String.format(
"%.${abs(power)}f",
(if (chartTitle.contains("效率")) yLabel + 80
else yLabel) * 10.0.pow(
power.toDouble()
)
),
0f,
yPositions[yLabelIndex],
frameworkPaint
)
}
}
绘制y轴文字依然是使用drawIntoCanvas来实现,但是这里需要根据y值相对于1的大小来确定文字对应的字符串。
所以这里首先判断数量级,如果字符串中需要显示小数点,则用String.format方法,将浮点数格式化成我们想要的字符串。