You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现可悬停、可点击的自定义连线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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 18:54:26