目录
- 前言
- DrawScope
- DrawScope Api
- 绘制柱状图
- 绘制 X 轴
- 绘制 Y 轴
- 绘制柱状背景
- 绘制柱状前景
- 完整代码
- 最终效果
- 存在的问题
前言
本文讲的是使用 compose
去自定义 View
,如果您未曾通过继承 View
的方式去实现自定义 View
,那么,我建议在观看本文之前呢,您先去了解一下如何通过继承 View
的方式实现自定义 View
,具体文章可阅读 👉 【Android】自定义View组件,并实现在 Compose、Kotlin、Xml 中调用
DrawScope
看到这里时,相信你已经学习了如何通过继承 View
,实现 onDraw
函数,并使用函数提供的 Canvas
实现自定义。而现在,我很遗憾的告诉你,在 Compose
的自定义 View
上,已经不再使用 Canvas
了。
如你所见,它只是嵌套了一层叫 Canvas
的皮:
真正的绘制方法,还得是 DrawScope
,这下明白为啥文章要起个 DrawScope
的目录了吧?
DrawScope Api
使用 Compose
自定义绘制一个柱状图,并不难,稍微有点难度的是其中的逻辑,你看吧,关于 DrawScope
的 Api
我也才只用了以下三个而已。
当然啦,DrawScope
的 Api
可不只有这一点,点击查看全部 Api
👉 DrawScope
Api | 用途 |
---|---|
drawText | 绘制TextMeasurer生成的现有文本布局。 |
drawLine | 使用给定的油漆在给定点之间绘制一条线。 |
drawRoundRect | 使用给定的Paint绘制一个圆角矩形。矩形是填充还是描边(或两者)由Paint.style控制。 |
绘制柱状图
绘制 X 轴
fun drawTextX() {val barHeight = ((value.data / maxDataValue) * drawHeight)val xOffset = (index + 1) * (barWidth + ColumnarDistance.SPACE_BETWEEN_BARS.distance)val yOffset = (size.height - ColumnarDistance.BOTTOM.distance) - barHeightdrawScope.drawText(textMeasurer = textMeasurer,text = value.name,// 在左上方开始绘制的起点topLeft = Offset(xOffset, yOffset + barHeight),style = TextStyle(color = Color.Gray, fontSize = 12.sp))
}
绘制 Y 轴
fun drawTextY() {val size = drawScope.sizeval textMax = data.maxOf { it.data }val yAxisHeight = (size.height - ColumnarDistance.TOP.distance - ColumnarDistance.BOTTOM.distance) * percentagedrawScope.drawText(textMeasurer,(textMax * (1 - percentage)).toInt().toString(),style = TextStyle(color = Color.Gray, fontSize = 12.sp),topLeft = Offset(0f, yAxisHeight + 15))
}
fun drawLineY() {drawScope.drawLine(color = if (listY[index] == 1f) Color.Black else Color.Gray,// 绘制线的起点Offset(ColumnarDistance.LEFT_Y_X.distance,yAxisHeight + ColumnarDistance.TOP.distance),// 绘制线的终点Offset(size.width,yAxisHeight + ColumnarDistance.TOP.distance),strokeWidth = 1f)
}
绘制柱状背景
fun drawColumnarBg(barbackground: Color) {val barWidth = (size.width - (ColumnarDistance.SPACE_BETWEEN_BARS.distance * dataSize) - ColumnarDistance.RIGHT.distance) / dataSizeval barHeight = ((value.data / maxDataValue) * drawHeight)val xOffset = (index + 1) * (barWidth + ColumnarDistance.SPACE_BETWEEN_BARS.distance)drawScope.drawRoundRect(// 设置柱状的背景色color = barbackground,// 在左上方开始绘制的起点topLeft = Offset(xOffset,ColumnarDistance.TOP.distance),// 从 topLeft 绘制的起点开始算起,应该绘制多高、多宽的矩形size = Size(barWidth, drawHeight))
}
绘制柱状前景
fun drawColumnar() {val barWidth = (size.width - (ColumnarDistance.SPACE_BETWEEN_BARS.distance * dataSize) - ColumnarDistance.RIGHT.distance) / dataSizeval barHeight = ((value.data / maxDataValue) * drawHeight)val xOffset = (index + 1) * (barWidth + ColumnarDistance.SPACE_BETWEEN_BARS.distance)val yOffset = (size.height - ColumnarDistance.BOTTOM.distance) - barHeightdrawScope.drawRoundRect(// 设置柱状的背景色color = barColors,// 在左上方开始绘制的起点topLeft = Offset(xOffset,yOffset),// 从 topLeft 绘制的起点开始算起,应该绘制多高、多宽的矩形size = Size(barWidth, barHeight))
}
完整代码
@Composable
fun Histogram(data: List<HistogramData>, modifier: Modifier = Modifier
) {Box(modifier = Modifier.wrapContentSize().padding(16.dp),) {BarChart(data,modifier = modifier.width(400.dp).height(400.dp))}
}@Composable
internal fun BarChart(data: List<HistogramData>,modifier: Modifier = Modifier,barColors: Color = colorResource(R.color.columnar),barBackground: Color = colorResource(R.color.columnar_background)
) {val textMeasurer = rememberTextMeasurer()Canvas(modifier) {// 画柱状、x 轴的文字drawColumnar(this, data, barColors, barBackground, textMeasurer)// 画 y 轴drawYAxis(this, textMeasurer, data)}
}/*** 绘制柱状*/
internal fun drawColumnar(drawScope: DrawScope,data: List<HistogramData>,barColors: Color,barbackground: Color,textMeasurer: TextMeasurer
) {val maxDataValue = data.maxOf { it.data }val size = drawScope.sizeval dataSize = data.size + 1val barWidth =(size.width - (ColumnarDistance.SPACE_BETWEEN_BARS.distance * dataSize) - ColumnarDistance.RIGHT.distance) / dataSizedata.forEachIndexed { index, value ->val drawHeight =size.height - ColumnarDistance.BOTTOM.distance - ColumnarDistance.TOP.distanceval barHeight = ((value.data / maxDataValue) * drawHeight)val xOffset = (index + 1) * (barWidth + ColumnarDistance.SPACE_BETWEEN_BARS.distance)val yOffset = (size.height - ColumnarDistance.BOTTOM.distance) - barHeight// 绘制柱状的背景drawScope.drawRoundRect(// 设置柱状的背景色color = barbackground,// 在左上方开始绘制的起点topLeft = Offset(xOffset,ColumnarDistance.TOP.distance),// 从 topLeft 绘制的起点开始算起,应该绘制多高、多宽的矩形size = Size(barWidth, drawHeight))// 绘制柱状drawScope.drawRoundRect(// 设置柱状的背景色color = barColors,// 在左上方开始绘制的起点topLeft = Offset(xOffset,yOffset),// 从 topLeft 绘制的起点开始算起,应该绘制多高、多宽的矩形size = Size(barWidth, barHeight))// 绘制柱状下边的文字drawScope.drawText(textMeasurer,value.name,// 在左上方开始绘制的起点topLeft = Offset(xOffset, yOffset + barHeight),style = TextStyle(color = Color.Gray, fontSize = 12.sp))}
}internal fun drawYAxis(drawScope: DrawScope,textMeasurer: TextMeasurer,data: List<HistogramData>
) {val listY = listOf(0f, 0.25f, 0.5f, 0.75f, 1f)val size = drawScope.sizeval textMax = data.maxOf { it.data }listY.forEachIndexed { index, percentage ->val yAxisHeight =(size.height - ColumnarDistance.TOP.distance - ColumnarDistance.BOTTOM.distance) * percentage// 绘制 y 轴文字drawScope.drawText(textMeasurer,(textMax * (1 - percentage)).toInt().toString(),style = TextStyle(color = Color.Gray, fontSize = 12.sp),topLeft = Offset(0f,yAxisHeight + 15))// 绘制 y 轴横线drawScope.drawLine(color = if (listY[index] == 1f) Color.Black else Color.Gray,// 绘制线的起点Offset(ColumnarDistance.LEFT_Y_X.distance,yAxisHeight + ColumnarDistance.TOP.distance),// 绘制线的终点Offset(size.width,yAxisHeight + ColumnarDistance.TOP.distance),strokeWidth = 1f)}
}
最终效果
存在的问题
在写本文的规划时,原本是想着仿 echarts 官网的柱状图来开发一个 Compose 代码版本的柱状图,搞到最后才发现,柱的前景在初始化的时候无法实现从下往上绘制的动画,如下:
在 Compose
中,对于动画的操作只能在 @Composable
函数里面进行,但 DrawScope
并不是一个 @Composable
函数,这也就意味着我们无法将动画设置在 Canvas
里。
可能有人会产生一些小问题,比如说:
1、Canvas
里面无法使用 @Composable
函数,那我把他移动到 Canvas
外面来不可以吗?
答:可以。但是柱状图绘制的过程中是需要使用遍历的方式来完成多条数据的绘制,每一条数据都需要根据遍历的列表的数值来得到应该为柱的前景设置多少的高度,也就意味着数组每遍历一次,就要更新一下 barHeight
的数值,barHeight
使用了 remember
接收值,就会导致重组,当前的 @Composable
就会被执行一次刷新,刷新后又给 barHeight
赋值,接着又触发重组…
不用 remember
吧,你更新了 barHeight
又不会触发 animateFloatAsState
的变化。
@Composable
fun draw() {var barHeight by remember { mutableStateOf(0f) }// 通过 animateFloatAsState 创建一个动画状态val animatedBarHeight by animateFloatAsState(targetValue = barHeight,animationSpec = androidx.compose.animation.core.tween(durationMillis = 1000))Canvas(modifier) {data.forEachIndexed { index, value ->// 计算柱需要的高度barHeight = ((value.data / maxDataValue) * drawHeight)drawScope.drawRoundRect(color = barColors,topLeft = Offset(xOffset, yOffset),size = Size(barWidth, animatedBarHeight)}}
}
2、data.forEachIndexed
使用 LaunchedEffect
?
答:不行。LaunchedEffect
也是一个 @Composable
函数,@Composable
只能在 @Composable
函数里面使用。
针对这一问题,或许可以使用自定义 Layout
的方式实现,即将柱状的前景和背景作为子组件实现…
源码地址 👉 GitCode
参考文档
1、Android Developers - DrawScope 参考文档
2、Android Developers - Compose 中的图形
3、学不动也要学,Jetpack Compose 实现自定义绘制