jetpack-compose实现消息气泡拖动效果

399 阅读3分钟

效果图

1154863c-2cc6-4e04-84fc-1a6f4a83c5ec.gif

分析思路

首先需要一个可以拖动的的圆,当拖动圆的时候,在原位置画一个新的圆,随着拖动的距离变大或缩小,更改原位置圆的半径,当超过一定的距离,不再绘制原位置的圆。还有个需要考虑的问题是,原位置的圆的半径变化并不直接是手指拖动的距离,否则产生的变化太快。绘制了两个圆后,剩下就是要考虑绘制两个圆之间的连接路径效果,这个需要用到二阶贝塞尔曲线的知识点。

二阶贝塞尔曲线

简单解释:给定一个起始点(红色)和一个终点(蓝色),再加一个控制点(绿色),可以得到一条曲线。

image.png

所以重点是如何算出起始点,控制点和终点的坐标。

计算各点的坐标

image.png A点所在的圆就是“分析思路”中原位置的圆,E点的圆为手势拖动的圆。
E点坐标(offsetX, offsetY),minDragDistance为AE长度,计算涉及到一点点小学或初中的数学基础。
这里唯一需要确定的是正负方向,坐标系中右方和下方为正方向。拿G点举例子:G点为(负,正),此时offsetY和offsetX都为正,算出来的sin和cos也为正,sin和cos与半径相乘后任然为正,由于G点符号为(负,正),所以G点的坐标可确定为circleStartX = 0 - circleRaidus * sin; circleStartY = 0 + circleRadius * cos。
其他点的符号同理可确定。这里的控制点取的是两个圆心连线的中点。

绘制贝塞尔路径

确定点的坐标后,后面就很简单了,仅仅是参数的传递。

path.reset()
path.moveTo(circleStartX, circleStartY)
path.quadraticBezierTo(controlX, controlY, bubbleEndX, bubbleEndY)
path.lineTo(bubbleStartX, bubbleStartY)
path.quadraticBezierTo(controlX, controlY, circleEndX, circleEndY)
drawPath(path, Color.Red)

依次调用moveTo,quadraticBezierTo,lineTo,quadraticBezierTo

附上完整代码供大家学习

data class DragStatus(
    //是否正在拖动
    val onDrag: Boolean = false,
    //是否超出最小的距离
    val onDragExceedDistance: Boolean = false,
    //超出距离,气泡消失
    val onDragComplete: Boolean = false
)

@OptIn(ExperimentalTextApi::class)
@Composable
fun MessageBubble() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }
    var controlX by remember { mutableStateOf(0f) }
    var controlY by remember { mutableStateOf(0f) }
    var sin by remember { mutableStateOf(0f) }
    var cos by remember { mutableStateOf(0f) }
    var circleStartX by remember { mutableStateOf(0f) }
    var circleStartY by remember { mutableStateOf(0f) }
    var bubbleEndX by remember { mutableStateOf(0f) }
    var bubbleEndY by remember { mutableStateOf(0f) }
    var bubbleStartX by remember { mutableStateOf(0f) }
    var bubbleStartY by remember { mutableStateOf(0f) }
    var circleEndX by remember { mutableStateOf(0f) }
    var circleEndY by remember { mutableStateOf(0f) }
    var dragStatus by remember {
        mutableStateOf(DragStatus())
    }
    var circleRadius by remember { mutableStateOf(MainActivity.RADIUS) }
    val bubbleRadius by remember { mutableStateOf(MainActivity.RADIUS) }
    var minDragDistance by remember { mutableStateOf(0f) }
    var previousDistance by remember { mutableStateOf(0f) }
    var textWidth by remember { mutableStateOf(0f) }
    var textHeight by remember { mutableStateOf(0f) }
    val textLayoutResult = rememberTextMeasurer().measure(AnnotatedString("66"))
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Button(modifier = Modifier.padding(top = 100.dp), onClick = {
            circleRadius = MainActivity.RADIUS
            previousDistance = 0f
            minDragDistance = 0f
            offsetX = 0f
            offsetY = 0f
            dragStatus = dragStatus.copy(
                onDrag = false,
                onDragExceedDistance = false,
                onDragComplete = false
            )
        }) {
            Text(text = "reset")
        }
        Canvas(modifier = Modifier, onDraw = {
            if (dragStatus.onDrag && !dragStatus.onDragExceedDistance) {
                //原点的圆
                drawCircle(Color.Red, circleRadius)
            }
        })
        val path = Path()
        Canvas(
            modifier = Modifier
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDrag = { change, dragAmount ->
                            offsetX += dragAmount.x
                            offsetY += dragAmount.y
                            minDragDistance = sqrt(
                                (offsetX.toDouble().pow(2.0) + offsetY.toDouble()
                                    .pow(2.0)).toFloat()
                            )
                            //这里为什么除以8?除以几都可以看你心情,主要是不让原点的圆因为短距离的拖动而缩小的太快
                            if (minDragDistance > previousDistance) {
                                circleRadius -= ((minDragDistance - previousDistance) / 8f)
                            } else if (minDragDistance < previousDistance) {
                                circleRadius += ((previousDistance - minDragDistance) / 8f)
                            }
                            previousDistance = minDragDistance
                            dragStatus = dragStatus.copy(onDrag = true)
                            if (minDragDistance > MainActivity.MIN_DRAG_DISTANCE) {
                                dragStatus = dragStatus.copy(onDragExceedDistance = true)
                            }
                        },
                        onDragEnd = {
                            dragStatus = dragStatus.copy(onDrag = false)
                            if (minDragDistance < MainActivity.MIN_DRAG_DISTANCE) {
                                //拖动距离小于设置的距离,让可拖动的小圆回到原点
                                offsetX = 0f
                                offsetY = 0f
                                minDragDistance = 0f
                                circleRadius = MainActivity.RADIUS
                                dragStatus = dragStatus.copy(
                                    onDrag = false,
                                    onDragExceedDistance = false
                                )
                            } else {
                                dragStatus = dragStatus.copy(
                                    onDragExceedDistance = true,
                                    onDragComplete = true
                                )
                            }
                        }
                    )
                }, onDraw = {
                //如果正在拖动小圆,并且没有超过一定距离,才画连接的路径
                if (dragStatus.onDrag && !dragStatus.onDragExceedDistance) {
                    controlX = (offsetX + 0) / 2
                    controlY = (offsetY + 0) / 2
                    sin = offsetY / minDragDistance
                    cos = offsetX / minDragDistance
                    circleStartX = 0f - circleRadius * sin
                    circleStartY = 0f + circleRadius * cos
                    bubbleEndX = offsetX - bubbleRadius * sin
                    bubbleEndY = offsetY + bubbleRadius * cos
                    bubbleStartX = offsetX + bubbleRadius * sin
                    bubbleStartY = offsetY - bubbleRadius * cos
                    circleEndX = 0f + circleRadius * sin
                    circleEndY = 0f - circleRadius * cos
                    println(
                        "---------------------------\n" +
                                "minDragDistance: $minDragDistance\n" +
                                "offsetX: $offsetX, offsetY: $offsetY\n" +
                                "($circleStartX, $circleStartY)\n" +
                                "($controlX, $controlY, $bubbleEndX, $bubbleEndY)\n" +
                                "($bubbleStartX, $bubbleStartY)\n" +
                                "($controlX, $controlX, $circleEndX, $circleEndY)\n" +
                                "---------------------------\n"
                    )
                    path.reset()
                    path.moveTo(circleStartX, circleStartY)
                    path.quadraticBezierTo(controlX, controlY, bubbleEndX, bubbleEndY)
                    path.lineTo(bubbleStartX, bubbleStartY)
                    path.quadraticBezierTo(controlX, controlY, circleEndX, circleEndY)
                    drawPath(path, Color.Red)
                }
                //画可以拖动的圆
                if (!dragStatus.onDragComplete) {
                    drawCircle(Color.Red, bubbleRadius, Offset(offsetX, offsetY))
                    textWidth = textLayoutResult.size.width.toFloat()
                    textHeight = textLayoutResult.size.width.toFloat()
                    drawText(
                        textLayoutResult, Color.White, topLeft = Offset(
                            offsetX - textWidth / 2,
                            offsetY - textHeight / 2
                        )
                    )
                }
            })
    }
}