本文示例由compose对齐线延伸
了解Canvas基础Api请查看 Compose Canvas绘制转盘
实现效果
Compose 工作流程
- 组合阶段 (Composition) : 构建 UI 树结构
- 布局阶段 (Layout) : 确定组件大小和位置
- 绘制阶段 (Draw) : 实际渲染像素
需要了解的知识点
Modifier修饰符
drawBehind
- 只在绘制阶段执行
- 跳过组合阶段(Composition)和布局阶段(Layout)
graphicsLayer
- 创建一个独立的绘制层(Layer)
- 允许跳过组合和布局阶段的重计算
- 仅当 Layer 属性变化时触发重绘
pointerInput 处理触摸和指针事件
- 向任意可组合项添加自定义手势
详细请查阅官方文档 developer.android.com/develop/ui/…
了解上面部分,开始代码
BarChart 图表组件
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun BarChart(
dataPoints: List<Int>,
modifier: Modifier = Modifier,
isRect: Boolean = true,
) {
val color = MaterialTheme.colorScheme.primary
//防止高度越界
val maxValue: Float = remember(dataPoints) { dataPoints.maxOrNull()!! * 1.2f }
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
with(density) {
val aspRatio = constraints.maxHeight / maxValue
val maxYBaseline = dataPoints.maxOrNull()!! * aspRatio
val minYBaseline = dataPoints.minOrNull()!! * aspRatio
//默认宽和间隔
val space = 20.dp.toPx()
val h = constraints.maxHeight.toFloat()
val w = constraints.maxWidth.toFloat()
val length = dataPoints.size
//实际需要绘制的的总宽度
val needWidth = 2 * space * length + space
//平移量
var offsetX by remember { mutableStateOf(0f) }
Layout(
content = {},
modifier = Modifier
//剪裁超出的内容
.clipToBounds()
.drawBehind {
//绘制x轴
drawLine(
color,
Offset(0f, constraints.maxHeight.toFloat()),
Offset(
constraints.maxWidth.toFloat(),
constraints.maxHeight.toFloat()
),
strokeWidth = 2.dp.toPx()
)
//绘制Y轴
drawLine(
color,
Offset(0f, constraints.maxHeight.toFloat()),
Offset(
0f,
0f
),
strokeWidth = 2.dp.toPx()
)
dataPoints.forEachIndexed { index, i ->
val indexWidth = (space * (index + 1) + 20.dp.toPx() * index) + offsetX
//只绘制内容区域
if (indexWidth < w && indexWidth > 0) {
if (isRect) {
//绘制矩形
drawRect(
color,
topLeft = Offset(indexWidth, h),
size = Size(20.dp.toPx(), -i * aspRatio)
)
} else {
//绘制折线
if (index + 1 < length) {
//绘制当前的index的前一个节点
if (indexWidth - 2 * space < 0f && index - 1 >= 0) {
drawLine(
color = color,
end = Offset(
indexWidth + space / 2,
h - (i * aspRatio)
),
start = Offset(
indexWidth - 2 * space,
h - (((dataPoints[index - 1])) * aspRatio)
),
strokeWidth = 2.dp.toPx()
)
}
//绘制当前节点
drawLine(
color = color,
start = Offset(
indexWidth + space / 2,
h - (i * aspRatio)
),
end = Offset(
indexWidth + 2 * space + space / 2,
h - (((dataPoints[index + 1])) * aspRatio)
),
strokeWidth = 2.dp.toPx()
)
}
//绘制点
drawCircle(
color = color,
4.dp.toPx(),
center = Offset(indexWidth + space / 2, h - (i * aspRatio))
)
}
}
}
}
.pointerInput(Unit) {
if (needWidth > w) {
//处理平移和处理滑动冲突
//compose 提供的detectTransformGestures手势监听,
// 无法处理滑动冲突,尝试修改
detectTransformPanGestures { _, pan ->
if (pan.x < 0) {
if (offsetX <= 0f && offsetX > w - needWidth) {
offsetX += pan.x
}
} else if (pan.x > 0) {
if (offsetX < 0f) {
offsetX += pan.x
if (offsetX > 0f) {
offsetX = 0f
}
}
}
}
}
}
.graphicsLayer {
translationX = offsetX
}
) { _, constraints ->
with(constraints) {
//创建自定义对齐线
///https://developer.android.com/develop/ui/compose/layouts/alignment-lines?hl=zh-cn
layout(
width = if (hasBoundedWidth) maxWidth else minWidth,
height = if (hasBoundedHeight) maxHeight else minHeight,
alignmentLines = mapOf(
MaxChartValue to minYBaseline.roundToInt(),
MinChartValue to maxYBaseline.roundToInt()
)
) {}
}
}
}
}
}
BarChart2 组件canvas 移动坐标系改进版
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun BarChart2(
dataPoints: List<Int>,
modifier: Modifier = Modifier,
isRect: Boolean = true,
) {
val color = MaterialTheme.colorScheme.primary
//防止高度越界
val maxValue: Float = remember(dataPoints) { dataPoints.maxOrNull()!! * 1.2f }
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
with(density) {
val aspRatio = constraints.maxHeight / maxValue
val maxYBaseline = dataPoints.maxOrNull()!! * aspRatio
val minYBaseline = dataPoints.minOrNull()!! * aspRatio
val space = 20.dp.toPx()
val h = constraints.maxHeight.toFloat()
val w = constraints.maxWidth.toFloat()
val length = dataPoints.size
val needWidth = 2 * space * length + space
var offsetX by remember { mutableStateOf(0f) }
Layout(
content = {},
modifier = Modifier
.clipToBounds()
.drawBehind {
//尝试转换坐标系
with(drawContext.canvas) {
save()
//转换成数学坐标轴
translate(0f, h)
scale(1f, -1f)
//x轴
drawLine(
color,
Offset(0f, 0f),
Offset(
w,
0f
),
strokeWidth = 2.dp.toPx()
)
//y轴
drawLine(
color,
Offset(0f, 0f),
Offset(
0f,
h
),
strokeWidth = 2.dp.toPx()
)
dataPoints.forEachIndexed { index, i ->
val indexWidth =
(space * (index + 1) + 20.dp.toPx() * index) + offsetX
//只绘制内容区域
if (indexWidth < w && indexWidth > 0) {
if (isRect) {
//矩形图
drawRect(
color,
topLeft = Offset(indexWidth, 0f),
size = Size(20.dp.toPx(), i * aspRatio)
)
} else {
//折线图
if (index + 1 < length) {
if (indexWidth - 2 * space < 0f && index - 1 >= 0) {
drawLine(
color = color,
end = Offset(
indexWidth + space / 2,
(i * aspRatio)
),
start = Offset(
indexWidth - 2 * space,
(((dataPoints[index - 1])) * aspRatio)
),
strokeWidth = 2.dp.toPx()
)
}
drawLine(
color = color,
start = Offset(
indexWidth + space / 2,
(i * aspRatio)
),
end = Offset(
indexWidth + 2 * space + space / 2,
(((dataPoints[index + 1])) * aspRatio)
),
strokeWidth = 2.dp.toPx()
)
}
drawCircle(
color = color,
4.dp.toPx(),
center = Offset(indexWidth + space / 2, (i * aspRatio))
)
}
}
}
restore()
}
}
.pointerInput(Unit) {
detectTransformPanGestures { _, pan ->
pan.x.loge()
if (needWidth < w) return@detectTransformPanGestures
if (pan.x < 0) {
if (offsetX <= 0f && offsetX > w - needWidth) {
offsetX += pan.x
}
} else if (pan.x > 0) {
if (offsetX < 0f) {
offsetX += pan.x
if (offsetX > 0f) {
offsetX = 0f
}
}
}
}
}
.graphicsLayer {
translationX = offsetX
}
) { _, constraints ->
with(constraints) {
layout(
width = if (hasBoundedWidth) maxWidth else minWidth,
height = if (hasBoundedHeight) maxHeight else minHeight,
//越大,y轴越小
alignmentLines = mapOf(
MaxChartValue to minYBaseline.roundToInt(),
MinChartValue to maxYBaseline.roundToInt()
)
) {}
}
}
}
}
}
PointerInput 自定义手势
//处理滑动冲突
suspend fun PointerInputScope.detectTransformPanGestures(
onGesture: (centroid: Offset, pan: Offset) -> Unit
) {
//监听所有手势事件
awaitEachGesture {
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
//等待第一个事件
awaitFirstDown(requireUnconsumed = false)
do {
//等待指针事件更新
val event = awaitPointerEvent()
//查询当前指针事件是否已经被其他手势处理器消费
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
//计算平移量
val panChange = event.calculatePan()
if (!pastTouchSlop) {
pan += panChange
//只消费水平方法的手势
val panMotion =
if (pan.x.absoluteValue > pan.y.absoluteValue) {
pan.x.absoluteValue
} else 0f
if (panMotion > touchSlop) {
pastTouchSlop = true
}
}
//发生平移事件
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
if (
panChange != Offset.Zero
) {
onGesture(
centroid,
panChange,
)
}
event.changes.fastForEach {
//位置变化消费指针事件
if (it.positionChanged()) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
}
BarChartMinMax 使用对齐线显示Y轴数字
@Composable
private fun BarChartMinMax(
dataPoints: List<Int>,
maxText: @Composable () -> Unit,
minText: @Composable () -> Unit,
modifier: Modifier = Modifier,
isRect: Boolean = true,
isBarChat2: Boolean = false,
) {
Layout(
content = {
maxText()
minText()
// Set a fixed size to make the example easier to follow
if (isBarChat2) {
BarChart2(dataPoints, Modifier.size(200.dp), isRect)
} else {
BarChart(dataPoints, Modifier.size(200.dp), isRect)
}
},
modifier = modifier
.fillMaxWidth()
.height(220.dp)
) { measurables, constraints ->
check(measurables.size == 3)
val placeables = measurables.map {
it.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val maxTextPlaceable = placeables[0]
val minTextPlaceable = placeables[1]
val barChartPlaceable = placeables[2]
// Obtain the alignment lines from BarChart to position the Text
val minValueBaseline = barChartPlaceable[MinChartValue]
val maxValueBaseline = barChartPlaceable[MaxChartValue]
layout(constraints.maxWidth, constraints.maxHeight) {
maxTextPlaceable.placeRelative(
x = 0,
y = maxValueBaseline - (maxTextPlaceable.height / 2)
)
minTextPlaceable.placeRelative(
x = 0,
y = minValueBaseline - (minTextPlaceable.height / 2)
)
barChartPlaceable.placeRelative(
x = max(maxTextPlaceable.width, minTextPlaceable.width) + 20,
y = 0
)
}
}
}
HorizontalAlignmentLine 自定义水平对齐线
private val MaxChartValue = HorizontalAlignmentLine(merger = { old, new ->
min(old, new)
})
private val MinChartValue = HorizontalAlignmentLine(merger = { old, new ->
max(old, new)
})
代码入口
@Preview
@Composable
fun ChartDataPreview() {
val scrollable = rememberLazyListState()
MyApplicationTheme {
LazyColumn(
state = scrollable,
modifier = Modifier
.fillMaxSize()
) {
item {
BarChartMinMax(
dataPoints = listOf(33, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 22, 33),
maxText = { Text("33") },
minText = { Text("4") },
modifier = Modifier
.padding(24.dp)
.background(Color.Gray),
isRect = false,
isBarChat2 = true
)
}
item {
BarChartMinMax(
dataPoints = listOf(33, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 22, 33),
maxText = { Text("33") },
minText = { Text("4") },
modifier = Modifier
.padding(24.dp)
.background(Color.Gray),
isRect = false
)
}
item {
BarChartMinMax(
dataPoints = listOf(33, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 22, 33),
maxText = { Text("33") },
minText = { Text("4") },
modifier = Modifier
.padding(24.dp)
.background(Color.Gray),
isRect = true
)
}
item {
BarChartMinMax(
dataPoints = listOf(33, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 18, 22, 33),
maxText = { Text("33") },
minText = { Text("4") },
modifier = Modifier
.padding(24.dp)
.background(Color.Gray),
isRect = false
)
}
}
}
}