最近在尝试着做一些常用的图表组件,在上一篇文章中已经用Compose做了一个具备基础功能的饼图组件,那么今天我们继续来使用Compose做一个折线图组件
需要实现的功能
在做之前我们来想一下,回忆一下平时见过的折线图组件都有哪些功能,是不是有几下几点
- 有x,y轴,x轴表示维度里面具体有哪几项,会将x轴等分,y轴表示一个个区间,区间的具体数据会根据所有数据的最大值改变而改变
- x轴会有文字介绍,表明x轴展示的是什么数据,同样y轴也会有文字介绍,表明y轴展示的是什么数据
- 在y轴上每一个区间刻度都会延伸出一条平行于x轴的直线,用处是清晰的展示折线图某一个节点位于哪个数值区间。
- 折线图内部某个维度的各个数据都用圆点表示,相邻数据的圆点之间用直线连接,圆点之间的连接需要自带动画效果
- 每个维度使用不一样的颜色来画折线
- 折线图顶部会有对应维度名称以及代表的折线颜色,名称与颜色的数量会随着折线数量的变化而改变
就想出来这些,可能还有别的功能,知道的可以在评论区告诉我一下,我给你们做,现在开始敲代码
准备数据
在上一篇文章中就说过,希望做出来的图表组件之间可以依靠一套数据实现互相切换展示,所以数据方面延用了上一篇,也就是数据类继续使用PieData
,只是因为一些与饼图组件上的差异,我们在PieData
里面新增两个字段,一个是lineColor
,表示折线的颜色,另一个是groupName
,表示当前折线所属维度的名称
现在我们使用PieData
来创建几组数据,来模拟几个公司在近五年的营业额,数据如下
尽管数据封装的有点不规范,相同的元素其实可以拎出来,但是不影响我们来做这个折线组件
绘制x,y轴
创建个函数名叫LineChart
,内部接收两个参数,一个是我们的数据源,另一个是Modifier
接着我们来做一件事情,来将整个画布分成n * n的若干个小格子,这么做的原因是由于我们绘制折线图的时候,必然会牵扯到获取坐标点Offset
,所以为了方便管理这些坐标点,会将所有格子的交叉点的x,y坐标分别管理在两个数组中,这样就可以通过计算下标值从两个数组中获取任意的坐标点,比如这里将画布分成300 * 300个小格子,代码如下
在Canvas
中获取了整个画布的大小mSize
,通过mSize
可以得到了画布的宽高,我们使用得到的宽高除上count
就得到了xy轴上的每个小格子的尺寸xUnit
和yUnit
,然后创建两个大小都是count
的xList
和yList
,两个数组里面分别存放着画布上所有点的x,y坐标,以上都是准备工作,下面开始绘制x,y轴的两条直线,我们都知道折线图的x轴在图的最下方,y轴在最左侧,但是我们在画布里面可不能画在最边上,毕竟一个原因是不好看,另一个是后面还要把年份和营业额写在x,y轴上,所以适当的在画布四周留上个空白,比如20个格子
有了这些以后,我们xy轴的两条直线就能绘制出来了
两条直线分别使用两个drawLine
函数给绘制出来了,我们可以看到两个函数内部的start
和end
属性,都是通过计算得出下标值,然后从xList
和yList
中取出对应的值,我们后面绘制其他元素也都是通过这种方式来获取坐标,比如现在我想平分x轴,在每个平分的位置上绘制个圆点刻度,在圆点下方将对应的年份绘制上去,那么首先需要先知道隔开多少距离画一个点,这个距离通过x轴的长度除上每个维度的数组大小得出
后面减去5个格子的目的是让最后一个点跟x轴的末尾有一点距离,有了这个值以后,就能计算出每个需要绘制的圆点刻度与年份的坐标点的下标值了,代码如下
现在运行一遍代码,就能看到我们的xy轴以及x轴上的圆点刻度和年份
同样的方式我们在y轴上也将平分用的圆点刻度和每个刻度对应的平行于x轴的直线绘制出来,代码如下
yLineUnit
就是y轴上每个刻度之间的距离,计算逻辑与x轴上的圆点基本相同,看下效果
第一个难点:y轴上的值
目前为止还算简单,接下来要遇到第一个难点,也就是y轴上每个刻度对应的值该怎么计算,要知道针对所有维度里面的数据,如果y轴上的数值过大,那么折线图绘制的区域会比较偏下,甚至与x轴基本贴近,如果y轴上的值过小,那么折线图很有可能有一部分是绘制不出来的,甚至是都绘制不出来,所以只有让y轴上的值跟着所展示的维度的值动态变化,才能达到折线图永远都绘制在画布的中间位置,为了做到这一点,首先需要做到的是找到所有维度里面的最大值,这里需要用到Comparator
比较器,在比较之前先要将所有数据装到同一个List
里面去
这里将所有数据都装到totalList
里面去之后,就需要对比所有PieData
里面的amount
属性,找出拥有最大值amount
的PieData
,最后将这个最大的amount
取出来,代码如下
有了最大值max
,是不是就可以把这个max
作为我们y轴最上面刻度的值了呢,不是的,因为通常这样的刻度都是为整数,比如100,1000,1500之类的,所以我们获得了max
之后,就需要计算一下比max
稍微大一些的整数是多少,这里我就想不到什么比较好的方法了,只能用个笨方法,维护个大小是100的数组,里面放着100个整数,大小分别都是各自下标乘以100,当然乘之前下标可以加一,防止第一个数字为0,最后在这个数组中过滤出所有比max
大的整数,取第一个作为我们y轴最顶部的刻度就好了,看代码
获得的topNum
就是y轴上最顶部的刻度值了,至于y轴上任意一个刻度值都可以用下面这个公式计算出来
it
就是下标值,最终完整的绘制y轴上的刻度的代码如下所示
运行一下代码看看效果
可以看到最顶部的值是1900,而我们所有数据里面最大的amount
的确是公司D里面2020年的营业额1844.18,为了验证这个y轴是不是可以动态改变,我们改变一下数据源里面的值,把公司D的数据去掉,看看y轴的值是什么样的
可以看到去掉公司D的数据之后,最大的amount
变成了公司C里面2023年的营业额1141.96,所以y轴最上面的刻度变成了1200,证明我们的y轴上的值的确可以动态改变,最后把x,y轴上的文字描述写上去,这两个坐标很好找,基本在绘制x,y轴的直线的时候点就已经有了,我们只需要稍作调整一下就行,这里就直接上代码了
绘制折线
接下来就是主要部分了,折线部分的绘制,折线本身就是每一个数据所代表的点连接而成,所以首先需要将这些点的坐标找出来,老规矩我们将这些点的x,y坐标的下标值分别维护在两个数组中,那么维护x坐标下标值的数组里面的数据很容易初始化,因为他们就是我们x轴上那几个刻度的横坐标,代码如下
pList
就是维护着折线图上所有数据代表的点的x坐标的下标值,至于y坐标就要费点脑细胞计算一下,首先我们需要计算出以xy轴原点作为起点,往上第几格是代表着对应的数据,那么需要转换一下,使用总长度5 * yLineUnit
去乘以每一个数据的amount
值与topNum
的比例,就得到对应的格子数,然后用count
-paddingCount
-计算出来的格子数,就可以获得y坐标的下标,代码如下
注意这里的pListX
和pListY
维护的只是下标,并不是绘制时候需要用到的坐标,真正的坐标到时候还要从pListX
和pListY
取出下标后从xList
与yList
中获取,这里尝试一下将所有圆点绘制出来,代码就是这样的
那么现在我们的画布上面所有数据代表的点已经都绘制出来了
有了点以后,现在就要把这些点连在一起组成我们要的折线,在不要求动画的前提下,我们可以创建Path
将这些点连在一起,最终绘制Path
就好了
最终得到的效果图里面我们的折线图已经出来了
但是这个肯定不是我们最终想要的,我们还需要让折线图出来的时候,有个从第一个点逐渐连线至第二个点,然后慢慢将所有点连起来的过程,明显使用drawPath
这个函数不能实现我们这个需求,得找找其他办法
第二个难点:让折线动起来
drawPath
既然不行那么只能使用drawLine
了,但是用了那么久的drawLine
也没听过怎么让drawLine
动起来啊,基本也是靠着start
点和end
点绘制出来的一根静态线,琢磨了半天最终想到个办法,虽然start
与end
之间是一根静态线,但是如果我们让end
点每个单位时间不一样,那么最终呈现的效果不就是这根线在逐渐变化吗,至于如何变化,那肯定是让end
的x,y坐标点分别使用animateFloatAsState
函数来计算,这里再创建个数据类LineModel
用来封装绘制每一根直线需要用到的数据
新建这么个LineModel
的原因主要是我们需要将endX
与endY
用animateFloatAsState
函数来生成,而在Canvas
中是无法直接使用animateFloatAsState
来创建动画变量的,所以我们需要一个以LineModel
为元素的数组
可以看到这里创建了一个LineModel
的二维数组,主要是因为折线图中所有折线都是同时绘制的,所以这里准备将所有折线里面第一根直线维护在单个数组中,有几个这样的数组取决于单个维度中有多少个数据,初始化这个二维数组的代码如下
这里首先创建了一个计数器,每过200毫秒loadPos
加一,loadPos
这个变量就代表着绘制折线里面的第几根直线,之所以初始值为-2是因为需要在第一次加一的时候,可以让函数animateFloatAsState
的start
值是从第一个点的坐标开始,而当绘制到最后一根直线的时候,animateFloatAsState
里面的start
值与end
值又变成了同一个点,表示动画结束,其他情况下,动画的目标值坐标就是下一个动画的起始坐标,我们在看下绘制部分
绘制的逻辑就很简单了,每条折线绘制直线的数量永远都比loadPos
这个变量要小,每次都是先画圆点再画直线,在loadPos
之后的圆点和直线都是暂时不绘制的,运行下代码看看现在折线的绘制效果
动态更改折线数量
现在折线在绘制的时候已经有了动画效果了,最后我们再实现一下让折线数量与传进来的维度数量相对应,并且在折线图顶部显示折线颜色与对应的公司名称,顶部的这些数据我们就用LazyRow
组件来展示,内部遍历一下payload
数组,渲染数据从payload
数组里面每一个子数组中取出,代码如下
我们看到呈现出来的效果就是上面这样,然后将这个LazyRow
与我们折线图的Canvas
放置在一个Box
组件里面,并且调整下LazyRow
的位置,让它顶部居中
接下来就是动态改变payload
的大小,现在默认是四个维度的数据都塞进来了,这里稍作修改,在折线图LineChart
组件顶部放三个CheckBox
,默认是加载一个维度数据,每个CheckBox
被勾选之后,动态再添加一个维度的数据进去
现在看下每次添加新数据后折线图是一个什么样子的展现效果
发现有点不对劲,添加新的数据之后,折线图并没有重新开始绘制的动画,而是直接把完整的折线展现出来了,造成这个的原因是主要是因为loadPos
这个变量,它在第一个折线显示完以后,大小已经变成单个维度数组的最大值了,所以必须让每次payload
大小改变的时候,重置一下loadPos
的大小,我们在LineChart
里面加一段代码就好了
我们最终效果就完成了,一起来看下
总结
这个折线图组件算是已经完成了,但还是存在着一个比较大的缺点,就是太受限于数据结构,折线图函数的数据源入参必须是个二维数组才可以,不过问题也不大,可以多暴露出一点主要的参数供调用方来定制,这样子无论什么样子的数据结构,只需要将这些参数提取出来,就能绘制这个折线图,这些细节也会后续在源码中逐步完善