Jetpack Compose 自定义触摸:二维拖拽监测 detectDragGestures

169 阅读4分钟

前言

二维拖拽是允许组件可以在平面上被自由拖动,不限制它的拖动方向。

可是 Compose 并没有直接提供监测二维拖动的 Modifier,只提供了监测一维拖动(水平或垂直)的 Modifier.draggable,那怎么办呢?

我们可以使用更底层的 Modifier.pointerInput 配合上 detectDragGestures 来实现二维拖动的监测,我们先来看看 Modifier.pointerInput

配合实现二维拖动

Modifier.pointerInput:底层指针事件的入口

Modifier.pointerInput 之前有提到过,你可以看我的这篇文章:传送门,所以我们就简要说说。

Modifier.pointerInput 是 Jetpack Compose 中处理所有的最底层指针事件的入口,它的函数原型:

fun Modifier.pointerInput(
    key1: Any?, 
    block: PointerInputEventHandler // 手势识别逻辑 lambda 表达式
): Modifier

key 参数改变时,pointerInput 就会重启,内部运行手势检测逻辑的协程会被取消,我们可以用它来改变内部的手势识别逻辑。

参数 block 提供了一个协程作用域,我们就是在这里进行手势的识别逻辑。并且作用域具有 PointerInputScope 环境,我们可以访问组件的尺寸、视图配置(如触摸阈值),并且使用一些非常方便的函数去识别手势,例如:

@Composable
fun PointerInputSample() {
    Box(
        Modifier
            .background(Color.Green)
            .size(100.dp)
            .pointerInput(key1 = Unit) {
                detectTapGestures(
                    onPress = { offset ->
                        println("你按压了绿色方块,按压位置:$offset")
                    },
                    onLongPress = { offset ->
                        println("你长按了绿色方块,长按位置:$offset")
                    },
                    onDoubleTap = { offset ->
                        println("你双击了绿色方块,双击位置:$offset")
                    },
                    onTap = { offset ->
                        println("你点击了绿色方块,点击位置:$offset")
                    }
                )
            }
    )
}

detectDragGestures

detectDragGestures 可以从原始的指针事件流中识别出二维拖动手势,它的函数原型:

@OptIn(ExperimentalFoundationApi::class)
suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = {}, // 手指按下并开始拖动(超过触摸阈值 touch slop)时的回调
    onDragEnd: () -> Unit = {}, // 拖动正常结束时的回调
    onDragCancel: () -> Unit = {}, // 拖动被打断的回调
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit // 手指每次拖动的回调(正在拖动)
)

先来看最核心的 onDrag 回调:

Modifier.pointerInput(key1 = Unit) {
    detectDragGestures { change, dragAmount ->

    }
}

dragAmount 参数是相对上一次回调发生时,手指的移动距离。

change 参数封装了当前的指针事件中,用于拖动手势的触摸点的详细信息,比如有触摸点的id(多点触摸下,有多个指针)、事件发生的时间、当前触摸的位置、上一次触摸的位置。

实际上dragAmount 参数是一个便捷参数,它的值是通过当前触摸的位置减去上一次触摸的位置计算得出的。

另外拖动开始的回调中,startPosition 参数是拖动开始的位置在组件内的坐标。

可拖动的文字组件

了解了这些,来写一个文字可以在屏幕上自由拖动的示例:

@Composable
fun DraggableTextSample() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    var offsetY by remember { mutableFloatStateOf(0f) }
    var isDragging by remember { mutableStateOf(false) } // 是否正在拖动

    Box(
        Modifier.fillMaxSize()
    ) {
        Text(
            text = if (isDragging) "我正在被拖动..." else "请随意拖动我!",
            modifier = Modifier
                .align(Alignment.Center) // 初始居中,偏移是相对于这个中心位置
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } 
                .background(if (isDragging) Color.Cyan.copy(alpha = 0.7f) else Color.LightGray) // 拖动时改变背景色
                .pointerInput(Unit) { 
                    detectDragGestures(
                        onDragStart = { startOffset ->
                            isDragging = true
                            Log.d(
                                "DraggableTextSample",
                                "拖动开始,初始触摸位置 (相对于组件左上角): $startOffset, 当前偏移: ($offsetX, $offsetY)"
                            )
                        },
                        onDragEnd = {
                            isDragging = false
                            Log.d("DraggableTextSample", "拖动结束,最终偏移: ($offsetX, $offsetY)")
                        },
                        onDragCancel = {
                            isDragging = false
                            Log.d("DraggableTextSample", "拖动被取消")
                        },
                        onDrag = { change, dragAmount ->
                            change.consume() // 消费掉位置变化,防止其他父组件响应此拖动

                            // 累积拖动带来的偏移量
                            offsetX += dragAmount.x
                            offsetY += dragAmount.y
                        }
                    )
                }
                .padding(12.dp)
        )
    }
}

运行效果:

拖动文字组件

注意:

如果不在 onDrag 回调中调用 change.consume() 消费掉传递的指针事件,会将指针事件传递给可滚动的父组件,导致拖动文字组件会触发父组件的滚动行为。

最后与 detectDragGestures 类似的函数还有三个:detectDragGesturesAfterLongPressdetectHorizontalDragGesturesdetectVerticalDragGestures

后两个对应一维的拖动监测,第一个用来做长按之后的拖动监测。

detectDragGestures 与 Modifier.draggable 对比

我们知道 Modifier.draggable 是用于一维拖动的,那它和我们现在讲的 detectDragGestures 有什么区别吗?

实际没有本质上的区别,但 Modifier.draggable 抽象层级更高,它内置了状态管理 (DraggableState),封装了拖动状态和一些内部控制方法,比如你可以使用代码控制拖动的行为:

@Composable
fun DraggableStateChange() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    val draggableState = rememberDraggableState { delta ->
        offsetX += delta
    }

    Box(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), 0) }
                .size(100.dp)
                .background(Green)
                .draggable(state = draggableState, orientation = Orientation.Horizontal)
        )
    }

    LaunchedEffect(key1 = Unit) {
        delay(2000)

        draggableState.drag {
            // 向右拖动 100 像素
            dragBy(200f)
        }
        delay(1000)

        draggableState.drag {
            // 向左拖动 100 像素
            dragBy(-200f)
        }
    }
}

运行结果:

自动化拖动

detectDragGestures 你要自己管理状态并实现相应逻辑。

使用原则:

如果要实现二维平面上的拖动,就必须使用 detectDragGestures,否则就那个满足需求用哪个,Modifier.draggable 通常更简单。