背景
大家在使用股票 APP时,往往希望能快速掌握市场动态,感知当日行情热度。除了查看行情页面顶部的上证、创业板以及沪深 300 等指数外,还有一个关键区域不容错过,那就是大盘分析模块。
该模块以清晰直观的方式呈现大盘市场的整体概况,涨跌分布一目了然。上涨和下跌的股票家数清晰罗列,让我们能够直观地感受当日市场行情的冷暖,助力我们迅速做出投资决策。
今天我们就使用Jetpack Compose中的Canvas组件,来实现一个:
大盘分析UI的实现效果
实现方式
要实现这种柱状图UI的话,如果使用传统的View来做的话,我们首先得继承自View,然后在onDraw()方法里调用canvas对象的drawRect()和drawText()等,像下面的代码这样:
class CustomBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val paint =
Paint(Paint.ANTI_ALIAS_FLAG)
.apply {
color = Color.BLUE
style = Paint.Style.FILL
}
private val path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
//canvas.drawPath(path, paint)
....
}
}
传统View是能够实现这种效果,但是毕竟相较麻烦了些,而且也不是未来UI开发的趋势。
那么,我们就来看看怎么使用Jetpack Compose里的Canvas组件来更简单的来实现这个UI:
Jetpack Compose中Canvas的用法
其实不论是使用传统自定义View里的Canvas,还是使用Jetpack Compose中Canvas来绘制各种图形,柱状图或是曲线图,更多的难点还是业务逻辑,api方法的调用都是那些常见的绘制方法。
比如下面Jetpack Compose中Canvas组件里的几个常用的方法:
绘制直线 drawLine
Canvas(modifier = Modifier.size(200.dp)) {
drawLine(
color = Color.Blue,
start = Offset(0f, size.height / 2),
end = Offset(size.width, size.height / 2),
strokeWidth = 5f
)
}
绘制矩形
绘制矩形drawRect,绘制圆角矩形为drawRoundRect里面传入一个CornerRadius(12.0f)对象。
Canvas(modifier = Modifier.size(200.dp)) {
drawRect(
color = Color.Green,
topLeft = Offset(50f, 50f),
size = Size(100f, 100f),
style = Fill
)
}
其他的画法这里就不过多介绍了,大家可以去Android官方的Reference中的Canvas进行参考:developer.android.google.cn/reference/k…
涨跌分布柱状图的具体实现
涨跌分布的柱状图,分为11根柱子,中间一根涨幅为0的柱子,就是平盘,两边有不同程度的涨跌幅柱子。柱子的高度是根据当前涨跌区间的家数来计算的,颜色也有不同。 我们看效果图发现涨停的柱子在最左边,跌停柱子在最右边。有些证券APP是涨停在最右边,跌停在最左边。在今天的例子里,并没有固定死这种不同风格,这只是数据列表的顺序问题,是支持灵活修改的。
下面我们来看看涨跌分布图实现的具体代码:
package com.finddreams.stockmarketdashboard
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
*涨跌分布柱状图
*Copyright (c) finddreams https://github.com/finddreams
*/
@Composable
fun UpDownBarChart(
modifier: Modifier = Modifier,
barList: List<BarData>
) {
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
) {
val chartWidth = size.width
val chartHeight = size.height
val chartBottom = chartHeight
// 动态计算柱子的宽度和间距
val totalBars = barList.size
val barWidth = chartWidth / (totalBars * 1.5f) // 柱子宽度为总宽度的1/1.5
val barSpacing = (chartWidth - (barWidth * totalBars)) / (totalBars - 1)
// 动态计算比例尺
val max = barList.maxOf { it.zdNum }
val itemHeight = if (max.toFloat() == 0f) 0f else chartHeight / max
barList.forEachIndexed { index, item ->
val barHeight = item.zdNum * itemHeight
val barSize = Size(barWidth, barHeight)
// 将柱子的左上角对齐到最左侧,调整间距分布
val barLeft = index * (barWidth + barSpacing)
val barTop = chartBottom - barHeight // 柱子顶部Y
val topLeft = Offset(barLeft, barTop)
// 绘制柱状图
drawRoundRect(color = item.barColor, topLeft = topLeft, size = barSize)
// 绘制顶部文字 (居中偏上)
drawCenteredText(
textMeasurer,
text = item.zdNum.toString(),
color = item.topTextColor,
centerX = barLeft + barWidth / 2, // 中心点位于柱子的中间
centerY = barTop,
offsetY = -12.dp.toPx(), // 距离顶部偏移
)
// 绘制底部文字 (居中偏下)
drawCenteredText(
textMeasurer,
text = item.zdfRange.toString(),
color = item.bottomTextColor,
centerX = barLeft + barWidth / 2, // 中心点位于柱子的中间
centerY = chartBottom,
offsetY = 4.dp.toPx() // 距离底部偏移
)
}
}
}
// 帮助函数:绘制文字
fun DrawScope.drawCenteredText(
textMeasurer: TextMeasurer,
text: String,
color: Color,
centerX: Float,
centerY: Float,
offsetY: Float = 0f,
fontSize: TextUnit = 10.sp
) {
val textLayoutResult = textMeasurer.measure(
text = AnnotatedString(text),
style = TextStyle(fontSize = fontSize),
)
val textWidth = textLayoutResult.size.width.toFloat()
val textOffset = Offset(
x = centerX - textWidth / 2,
y = centerY + offsetY
)
drawText(textLayoutResult = textLayoutResult, color = color, topLeft = textOffset)
}
看代码,实现起来比用传统的自定义View来写简单明了,其中大部分代码都是确定宽高,位置,最后使用了drawRoundRect,drawText把柱子和柱子上面以及底部的文字绘制出来。
我们着重看一下绘制文字的部分:
绘制文字
Jetpack Compose 中Canvas组件,发现它绘制线,和Path,矩形,圆形等都和原来View系统里的写法都差不多,但是绘制文字的时候是有一些区别。
View里面drawText,只需要传入文字,x和y坐标,以及画笔就行了,颜色和文字大小等属性都在Paint里。
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
super.drawText(text, x, y, paint);
}
而Compose里Canvas组件绘制文字需要的参数要更多一些,我们来看下,发现它重载了多个方法
我这里使用的drawText方法需要传入,TextLayoutResult,Color,以及左上角的topLeft: Offset;
drawText(textLayoutResult = textLayoutResult, color = color, topLeft = textOffset)
TextLayoutResult 其实来自于TextMeasurer对象
val textLayoutResult = textMeasurer.measure(
text = AnnotatedString(text),
style = TextStyle(fontSize = fontSize),
)
回到我们这个UpDownBarChart Compose方法组件第一行,写了一个
val textMeasurer = rememberTextMeasurer()
TextMeasurer是用来在Canvas组件绘制前测量文本的宽度、高度、换行等信息,系统使用了
remember函数缓存了TextMeasurer对象,避免在每次重组时的重复测量。
在TextLayoutResult 对象中我们可以设置文字和文字的样式。为了使文字绘制在矩形的中间,我们需要获取到文字的宽度,从TextMeasurer测量方法measure返回的结果textLayoutResult中获取的文字的宽度从而计算出文字应该显示的x,y坐标,组成Offset对象,传给drawText方法,最后把文字显示在每根柱子的中间位置。
val textWidth = textLayoutResult.size.width.toFloat()
val textOffset = Offset(
x = centerX - textWidth / 2,
y = centerY + offsetY
)
drawText(textLayoutResult = textLayoutResult, color = color, topLeft = textOffset)
涨跌数量横向对比进度条
涨跌分布图的下面,伴随涨跌数量的对比进度条,我们可以从中更直观的看到今日的大盘,有多少家公司是上涨的,多少家是下跌的,更加简洁明了。
代码的具体实现如下:
@Composable
fun UpDownHorizontalBar(
progress1: Float,
color1: Color,
progress2: Float,
color2: Color,
progress3: Float,
color3: Color,
modifier: Modifier = Modifier,
spacing: Dp,
minBarWidth: Dp = 4.dp // 设置最小宽度
) {
Canvas(modifier = modifier) {
val totalWidth = size.width
val height = size.height
val spacingPx = spacing.toPx()
val minBarWidthPx = minBarWidth.toPx()
// 初步计算每段的宽度
var width1 = max(totalWidth * progress1, minBarWidthPx)
var width2 = max(totalWidth * progress2, minBarWidthPx)
val remainingWidth = totalWidth - (width1 + width2 + spacingPx * 2)
// 确保 width3 不低于最小宽度
if (remainingWidth >= minBarWidthPx) {
remainingWidth
} else {
// 如果剩余空间不足,则压缩前两段的宽度
val deficit = minBarWidthPx - remainingWidth
val adjustmentFactor = deficit / (width1 + width2)
width1 *= (1 - adjustmentFactor)
width2 *= (1 - adjustmentFactor)
minBarWidthPx
}
val slantOffset = 13.dp.toPx() // 设置斜边偏移量
val cornerRadius = min(height / 2, 13.dp.toPx())
// 第一段路径
val path1 = Path().apply {
moveTo(cornerRadius, 0f)
lineTo(width1, 0f)
lineTo(width1 - slantOffset, height)
lineTo(cornerRadius, height)
arcTo(
rect = Rect(0f, 0f, 2 * cornerRadius, height),
startAngleDegrees = 90f,
sweepAngleDegrees = 180f,
forceMoveTo = false
)
close()
}
drawPath(path1, color1)
// 第二段路径
val path2 = Path().apply {
moveTo(width1 + spacingPx, 0f)
lineTo(width1 + width2 + spacingPx, 0f)
lineTo(width1 + width2 - slantOffset + spacingPx, height)
lineTo(width1 + spacingPx - slantOffset, height)
close()
}
drawPath(path2, color2)
// 第三段路径
val path3 = Path().apply {
moveTo(width1 + width2 + 2 * spacingPx, 0f)
lineTo(totalWidth - cornerRadius, 0f)
arcTo(
rect = Rect(totalWidth - 2 * cornerRadius, 0f, totalWidth, height),
startAngleDegrees = -90f,
sweepAngleDegrees = 180f,
forceMoveTo = false
)
lineTo(width1 + width2 - slantOffset + 2 * spacingPx, height)
close()
}
drawPath(path3, color3)
}
}
@Composable
fun UpDownHorizontalBar(riseNum: Int, fallNum: Int, flatNum: Int) {
val totalProgress = riseNum + fallNum + flatNum
// 计算占比
val ratioRise = (riseNum.toFloat() / totalProgress)
val ratioFall = (fallNum.toFloat() / totalProgress)
val ratioFlat = (flatNum.toFloat() / totalProgress)
UpDownHorizontalBar(
progress1 = ratioRise,
color1 = ColorStockRed,
progress2 = ratioFlat,
color2 = ColorStockGray,
progress3 = ratioFall,
color3 = ColorStockGreen,
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
spacing = 6.dp
)
}
使用的方式,传入今日不同的涨跌家数就可以了:
val riseNum = 3007
val flatNum = 201
val fallNum = 2180
// 总 progress
UpDownHorizontalBar(riseNum, fallNum, flatNum)
最后是大盘分析完整UI页面的代码
@Composable
private fun Content(modifier: Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = "大盘分析", fontSize = 18.sp, fontWeight = FontWeight.Bold)
UpDownBarChart(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(vertical = 25.dp),
getBarDataList()
)
val riseNum = 3007
val flatNum = 201
val fallNum = 2180
// 总 progress
UpDownHorizontalBar(riseNum, fallNum, flatNum)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "涨${riseNum}家", fontSize = 13.sp, color = ColorStockRed)
Text(text = "跌${fallNum}家", fontSize = 13.sp, color = ColorStockGreen)
}
}
}
getBarDataList()方法返回的是我们给不同柱子的文字,颜色,和涨跌数量数据类BarData的集合。
具体实现如下:
fun getBarDataList(): List<BarData> {
val barList = arrayListOf<BarData>()
barList.add(
BarData(
"涨停",
53,
ColorStockRed1,
topTextColor = ColorStockRed,
bottomTextColor = ColorStockRed
)
)
barList.add(BarData(">7%", 43, ColorStockRed2, topTextColor = ColorStockRed))
barList.add(BarData("7-5%", 75, ColorStockRed3, topTextColor = ColorStockRed))
barList.add(BarData("5-2%", 474, ColorStockRed4, topTextColor = ColorStockRed))
barList.add(BarData("2-0%", 2362, ColorStockRed5, topTextColor = ColorStockRed))
barList.add(BarData("平", 201, ColorStockGray, topTextColor = ColorStockGray))
barList.add(BarData("0-2%", 1400, ColorStockGreen1, topTextColor = ColorStockGreen))
barList.add(BarData("2-5%", 603, ColorStockGreen2, topTextColor = ColorStockGreen))
barList.add(BarData("5-7%", 76, ColorStockGreen3, topTextColor = ColorStockGreen))
barList.add(BarData("7%<", 59, ColorStockGreen4, topTextColor = ColorStockGreen))
barList.add(
BarData(
"跌停", 42, ColorStockGreen5,
topTextColor = ColorStockGreen,
bottomTextColor = ColorStockGreen
)
)
return barList
}
data class BarData(
val zdfRange: String,
val zdNum: Int,
val barColor: Color,
val topTextColor: Color = ColorTextColor,
val bottomTextColor: Color = ColorTextColor
)
总结
实现这样一个大盘分析的UI效果,使用Jetpack Compose中的Canvas组件实现起来还是很容易的。而且写的过程中,同样是画柱状图,使用Compose中的Canvas组件预览UI比xml里面的自定义View方便多了。
像下面这样,你代码里修改一下高度和颜色,预览效果马上就能体现出来,不用重新运行APP,简直省心省力。
如果之前经常用自定义View来绘制各种柱状图的小伙伴,来使用Jetpack Compose 中的Canvas组件绘制上手也不难,没用过的可以快快尝试用起来,更简洁和声明式的方式来画画图,也是一种不一样的体验。
后期可拓展:
- 增加动画,使柱状图更加生动;
- 定时刷新UI,保持数据更新;
源码
最后附上该UI示例的源代码地址,给大家参考:github.com/finddreams/…