Compose 图表折线图及手势冲突

325 阅读4分钟

本文示例由compose对齐线延伸

了解Canvas基础Api请查看 Compose Canvas绘制转盘

实现效果

QQ_1745043949827.png

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
                )
            }

        }
    }
}

完整效果图

QQ_1745043612943.png