如何实现可悬停、可点击的自定义连线Compose组件(支持贝塞尔曲线扩展)
实现可交互的自定义形状(线段/贝塞尔曲线)组件
核心思路是放弃依赖Compose默认的布局区域命中检测,手动实现触摸点与目标形状的碰撞判定,通过Modifier.pointerInput监听触摸事件,结合几何计算判断是否命中形状,进而触发悬停、点击逻辑。
一、线段的可交互实现
1. 点到线段的距离计算工具函数
先实现几何计算函数,判断触摸点到线段的最短距离是否在可点击阈值内:
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toPx /** * 计算点到线段的最短距离 */ fun distanceToLine(point: Offset, lineStart: Offset, lineEnd: Offset): Float { val lineVec = lineEnd - lineStart val pointVec = point - lineStart val lineLengthSquared = lineVec.dot(lineVec) // 处理两点重合的情况 if (lineLengthSquared == 0f) return pointVec.getDistance() // 计算点在直线上的投影比例,限制在线段范围内 val projectionRatio = pointVec.dot(lineVec) / lineLengthSquared val clampedRatio = projectionRatio.coerceIn(0f, 1f) val projectionPoint = lineStart + lineVec * clampedRatio return (point - projectionPoint).getDistance() } /** * 判断触摸点是否命中线段(带阈值,模拟线段的可点击宽度) */ fun isPointOnLine( point: Offset, lineStart: Offset, lineEnd: Offset, clickThreshold: Dp = 4.dp ): Boolean { val thresholdPx = clickThreshold.toPx() return distanceToLine(point, lineStart, lineEnd) <= thresholdPx }
2. 可交互线段组件实现
用drawWithCache绘制线段,同时通过pointerInput处理触摸事件,维护悬停状态:
import androidx.compose.foundation.gestures.detectHoverGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput @Composable fun InteractiveLine( start: Offset, end: Offset, normalColor: Color = Color.Black, hoverColor: Color = Color.Blue, lineWidth: Float = 2f, clickThreshold: Dp = 4.dp, onClick: () -> Unit ) { var isHovered by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectHoverGestures( onHover = { offset -> isHovered = isPointOnLine(offset, start, end, clickThreshold) }, onExit = { isHovered = false } ) } .pointerInput(Unit) { detectTapGestures( onPress = { offset -> if (isPointOnLine(offset, start, end, clickThreshold)) { onClick() } } ) } ) { Box( modifier = Modifier .fillMaxSize() .drawWithCache { onDrawBehind { drawIntoCanvas { canvas -> canvas.nativeCanvas.drawLine( start.x, start.y, end.x, end.y, android.graphics.Paint().apply { color = if (isHovered) hoverColor.toArgb() else normalColor.toArgb() strokeWidth = lineWidth isAntiAlias = true } ) } } } ) } }
二、扩展到贝塞尔曲线的实现
贝塞尔曲线的碰撞检测可以用分段近似法(将曲线拆分为多个小线段,逐个检测),平衡精度与性能:
1. 贝塞尔曲线分段与碰撞检测函数
import androidx.compose.ui.geometry.Offset /** * 将二次贝塞尔曲线拆分为多个小线段 */ fun splitQuadraticBezier( start: Offset, control: Offset, end: Offset, segments: Int = 20 ): List<Offset> { val points = mutableListOf<Offset>() for (i in 0..segments) { val t = i.toFloat() / segments val x = (1 - t) * (1 - t) * start.x + 2 * (1 - t) * t * control.x + t * t * end.x val y = (1 - t) * (1 - t) * start.y + 2 * (1 - t) * t * control.y + t * t * end.y points.add(Offset(x, y)) } return points } /** * 判断触摸点是否命中二次贝塞尔曲线 */ fun isPointOnQuadraticBezier( point: Offset, start: Offset, control: Offset, end: Offset, clickThreshold: Dp = 4.dp ): Boolean { val segments = splitQuadraticBezier(start, control, end) for (i in 0 until segments.lastIndex) { if (isPointOnLine(point, segments[i], segments[i+1], clickThreshold)) { return true } } return false }
2. 可交互贝塞尔曲线组件实现
参考线段组件逻辑,替换碰撞检测函数即可:
@Composable fun InteractiveQuadraticBezier( start: Offset, control: Offset, end: Offset, normalColor: Color = Color.Black, hoverColor: Color = Color.Blue, lineWidth: Float = 2f, clickThreshold: Dp = 4.dp, onClick: () -> Unit ) { var isHovered by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectHoverGestures( onHover = { offset -> isHovered = isPointOnQuadraticBezier(offset, start, control, end, clickThreshold) }, onExit = { isHovered = false } ) } .pointerInput(Unit) { detectTapGestures( onPress = { offset -> if (isPointOnQuadraticBezier(offset, start, control, end, clickThreshold)) { onClick() } } ) } ) { Box( modifier = Modifier .fillMaxSize() .drawWithCache { onDrawBehind { drawIntoCanvas { canvas -> canvas.nativeCanvas.drawQuadTo( start.x, start.y, control.x, control.y, end.x, end.y, android.graphics.Paint().apply { color = if (isHovered) hoverColor.toArgb() else normalColor.toArgb() strokeWidth = lineWidth style = android.graphics.Paint.Style.STROKE isAntiAlias = true } ) } } } ) } }
关键注意事项
- 阈值设置:
clickThreshold建议设为比线段宽度稍大的值,提升用户点击体验,避免过细线段难以触发交互。 - 性能优化:贝塞尔曲线的分段数不用过高,20段左右足够平衡精度和性能;复杂曲线可根据长度动态调整分段数。
- 悬停逻辑:贝塞尔曲线的悬停需在
onHover中实时检测触摸点,而非依赖onEnter,因为组件布局范围远大于曲线区域。
内容的提问来源于stack exchange,提问作者Shazniq
相关产品推荐
相关产品推荐

