先看效果图:

android折线图表 android绘制折线图_字符串

 需要用到的知识点:

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这个参数,所以这里只能使用二阶贝塞尔曲线来绘制。

android折线图表 android绘制折线图_Android_02

二阶贝塞尔曲线每次绘制一段曲线需要一个控制点和终点,这里我把数据点集合中的上一个坐标点作为控制点,而上一段曲线的的控制点和本段的坐标点的中点作为终点:

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方法,将浮点数格式化成我们想要的字符串。