Skip to content

手势处理

更新: 1/31/2026 字数: 0 字 时长: 0 分钟

常用手势处理 Modifier

Clickable 点击

kotlin
Box(modifier = Modifier
        .size(200.dp)
        .background(Color.Green)
        .clickable(enabled = enableState) {
            // ...
        })

CombinedClickable 复合点击

kotlin
fun Modifier.combinedClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onLongClickLabel: String? = null,
    onLongClick: (() -> Unit)? = null,
    onDoubleClick: (() -> Unit)? = null,
    onClick: () -> Unit
)
kotlin
.combinedClickable(
    enabled = enableState,
    onLongClick = {
        Log.d("zzx", "长按点击")
    },
    onDoubleClick = {
        Log.d("zzx", "双击")
    },
    onClick = {
        Log.d("zzx","单击")
    }
))

Draggable 拖动

Draggable 修饰符允许开发者监听 UI 组件的拖动手势偏移量,Draggable 修饰符只能监听垂直方向或水平方向的偏移。

kotlin
fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = NoOpOnDragStarted,
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = NoOpOnDragStopped,
    reverseDirection: Boolean = false
)

使用 Draggable 修饰符最少需要传入两个参数:draggableState,orientation。

  • draggableState:可以获取到拖动手势的偏移量,并且也允许我们动态控制发生偏移行为。
  • orientation:监听的拖动手势方向,只能为水平(Horizontal)或垂直方向(Vertical)。

使用 draggable 完成一个滑块拖动:

kotlin
var offsetX by remember { mutableStateOf(0f) }
val boxSlideLengthDp = 50.dp
val boxSlideLengthPx = with(LocalDensity.current) {
    boxSlideLengthDp.toPx()
}
val draggableState = rememberDraggableState {
    offsetX = (offsetX + it).coerceIn(0f,3 * boxSlideLengthPx)
}
Box(Modifier
        .width(boxSlideLengthDp * 4)
        .height(boxSlideLengthDp)
        .background(Color.LightGray)
       ) {
    Box(
        Modifier
            .size(boxSlideLengthDp)
            .offset {
                IntOffset(offsetX.roundToInt(), 0)
            }
            .draggable(
                orientation = Orientation.Horizontal,
                state = draggableState
            )
            .background(Color.DarkGray)
    )
}

offsetX 是用来控制 box 真正的偏移量的,使用 rememberDraggableState创建了一个 DraggableState 对象,每次被拖动时都会触发 onDelta 回调,并传入 it 这个偏移量参数,并对 offset 进行累加并限制区间范围,改变他的偏移量,然后 Box 发生重组。然后为 draggable 修饰符设置 orientation 和 draggableState 就好了。

INFO

由于 Modifier 是链式执行的,所以此时 offset 修饰符需要在 draggable 修饰符与 background 修饰符之前执行。

kotlin
Box(
            Modifier
                .size(boxSlideLengthDp)
                .draggable(
                    orientation = Orientation.Horizontal,
                    state = draggableState
                )
                .offset {
                    IntOffset(offsetX.roundToInt(), 0)
                }
                .background(Color.DarkGray)
        )

如果写成上面那样,就会发生视觉上正常偏移了,但是可拖拽部分仍未变。

Transformable 多点触控

kotlin
@Composable
fun TransformableDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    var ratationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    var transformableState = rememberTransformableState { zoomChange, panChange, rotationChange ->
        scale *= zoomChange
        offset += panChange
        ratationAngle += rotationChange
    }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Box(Modifier
            .size(boxSize)
            .rotate(ratationAngle) // 要先于offset调用
            .offset() {
                IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
            }
            .scale(scale)
            .background(Color.Green)
            .transformable(
                state = transformableState,
                lockRotationOnZoomPan = false
            ))
    }
}

Scrollable 滚动

horizontalScroll 水平滚动

kotlin
fun Modifier.horizontalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
)

必选的只有 state,可以使用 rememberScrollState 快速创建一个 scrollState 实例并传入。

verticalScroll 垂直滚动

与 horizontalScroll 同理。

scrollable 修饰符

kotlin
fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
)

scrollState 的 value 字段表示当前滚动位置,范围为 0-MAX_VALUE,默认情况是向右滑滚动位置增大,向左滑滚动位置减小。由于滚动位置默认初始为 0,所以只能向右增大滚动位置。如果将 scrollable 中的 reverseDirection 参数设为 true 时,则向左滑滚动位置会增大,向右滑滚动位置会减小,这允许我们在初始位置向左滑动。

INFO

在使用 rememberScrollState 创建 ScrollState 实例时,可以通过 initial 参数来指定组件初始滚动位置的。

下面来实现一个横向滚动的组件。

kotlin
@Composable
fun RowScrollDemo(
    content: @Composable () -> Unit
) {
    var scrollState = rememberScrollState()
    Row(
        modifier = Modifier
            .height(136.dp)
            .scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
            .layout { measurable, constraints ->
                // 约束中默认最大宽度为父组件所允许的最大宽度,此处为屏幕宽度
                // 设为无限大
                val childConstraints = constraints.copy(
                    maxWidth = Constraints.Infinity
                )
                // 使用新约束进行组件测量
                val placeable = measurable.measure(childConstraints)
                // 计算当前组件宽度与父组件所允许最大宽度取一个最小值
                val width = placeable.width.coerceAtMost(constraints.maxWidth)
                val height = placeable.height.coerceAtMost(constraints.maxHeight)
                // 计算可滚动的距离
                val scrollDistance = placeable.width - width
                // 设置组件的宽高
                layout(width, height) {
                    val scroll = scrollState.value.coerceIn(0, scrollDistance)
                    val xOffset = -scroll
                    placeable.placeRelativeWithLayer(xOffset, 0)
                }
            }
    ) {
        content()
    }
}

定制手势处理

PointerInput Modifier

上面提到的手势处理修饰符都是基于低级别的 PointerInput 修饰符进行封装实现的。

kotlin
fun Modifier.pointerInput(
    key1: Any?,
    block: suspend PointerInputScope.() -> Unit
): Modifier = this then SuspendPointerInputElement(
    key1 = key1,
    pointerInputHandler = block
)

使用时,需要传入两个参数:keys 与 block。

  • keys: 当 Composable 发生重组时,如果传入的 key 发生了变化,则手势事件处理过程会被中断。
  • block: 在这个 PointerInputScope 类型作用域代码块中,便可以声明手势事件处理逻辑了。

在 PointerInputScope 接口声明中能够找到所有可用的手势处理方法,可以通过这些方法获取更详细的手势信息,以及更加细粒度的手势事件处理。

detectTapGestures

用来设置更细粒度的点击监听回调。

kotlin
suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((Offset) -> Unit)? = null, // 双击时回调
    onLongPress: ((Offset) -> Unit)? = null, // 长按时回调
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, // 按下时回调
    onTap: ((Offset) -> Unit)? = null // 轻触时回调
)

这几个回调存在先后顺序。onPress 是最普通的 ACTION_DOWN 事件,一按下就会回调。比如连按两下就会触发两次 onPress 后触发 onDoubleTap,onLongPress 和 onTap 触发前也会触发 onPress。

detectDragGestures

  • detectDragGestures: 监听任意方向的拖动手势
  • detectDragGesturesAfterLongPress: 监听长按后的拖动手势。
  • detectHorizontalDragGestures:监听水平拖动手势。
  • detectVerticalDragGestures:监听垂直拖动手势。

以第一个为例:

kotlin
suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

这里提供了四个回调时机,onDragStart 会在拖动开始时回调,onDragEnd 会在拖动结束时回调,onDragCancel 会在拖动取消时回调,而 onDrag 则会在拖动真正发生时回调。

INFO

onDragCancel 触发时机多发生于滑动冲突的场景,子组件可能最开始是可以获取到滑动事件,不过可能被父组件拦截,这时便会执行 onDragCancel 回调。

下面是一个运用的 demo:

kotlin
val boxSize = 100.dp
var offset by remember { mutableStateOf(Offset.Zero) }
Box(contentAlignment = Alignment.Center,
    modifier = Modifier.fillMaxSize()
   ) {
    Box(Modifier
            .size(boxSize)
            .offset {
                IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
            }
            .background(Color.Green)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { offset: Offset ->
                        Log.d("zzx","滑动开始")
                    },
                    onDragEnd = {
                        Log.d("zzx","滑动结束")
                    },
                    onDragCancel = {
                        Log.d("zzx","滑动取消")
                    },
                    onDrag = { change, dragAmount ->
                        offset += dragAmount
                    }
                )
            }
       )
}

detectTranformGestures

可以获取到双指拖动,缩放与旋转手势操作中更具体的手势信息,例如重心。

kotlin
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
  • panZoomLock: 当拖动或缩放手势时是否支持旋转
  • onGesture: 当拖动,缩放或旋转手势发生时回调

很简单,根据手势信息更新状态就可以了。

kotlin
val boxSize = 100.dp
var offset by remember { mutableStateOf(Offset.Zero) }
var rotationAngle by remember { mutableStateOf(0f) }
var scale by remember { mutableStateOf(1f) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(Modifier
            .size(boxSize)
            .rotate(rotationAngle)
            .scale(scale)
            .offset {
                IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
            }
            .background(Color.Green)
            .pointerInput(Unit) {
                detectTransformGestures(
                    panZoomLock = true, // 平移或缩放时不允许旋转
                    onGesture = { centroid, pan, zoom, rotation ->
                        offset += pan
                        scale *= zoom
                        rotationAngle += rotation
                    }
                )
            })
}

awaitEachGesture

手势操作是在协程中进行监听的,如果处理完一轮手势交互后,便会结束,进行第二次手势交互时由于协程结束故手势事件便会被丢弃掉。而用 while 包裹则会导致如果手势仍留在屏幕上就会影响第二轮的手势。所以可以用 forEachGesture 方法保证每一轮手势处理逻辑的一致性。

kotlin
suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    awaitPointerEventScope {
        while (currentContext.isActive) {
            try {
                block()

                // Wait for all pointers to be up. Gestures start when a finger goes down.
                awaitAllPointersUp()
            } catch (e: CancellationException) {
                if (currentContext.isActive) {
                    // The current gesture was canceled. Wait for all fingers to be "up" before
                    // looping again.
                    awaitAllPointersUp()
                } else {
                    // detectGesture was cancelled externally. Rethrow the cancellation exception to
                    // propagate it upwards.
                    throw e
                }
            }
        }
    }
}

每一轮手势处理结束后,或本次手势处理被取消时,都会使用awaitAllPointersUp()保证所有手指均抬起。

手势事件方法作用域 awaitPointerEventScope

手势监听在 compose 中是用协程的挂起恢复实现的。PointerInputScope 允许通过使用 awaitPointerEventScope 方法获得 AwaitPointerEventScope 作用域,这个作用域中可以使用 Compose 中所有低级别手势处理挂起方法。当所有手势事件都处理完成后,awaitPointerEventscope 便会恢复执行将 Lambda 最后一行表达式的数值作为返回值返回。

awaitPointerEvent

许多上层手势监听都是基于这个 API 实现的,无论按下抬起移动都被视作一次手势事件,当手势发生时,awaitPointerEvent 会返回当前监听到的所有手势交互信息。

kotlin
awaitEachGesture {
    var event = awaitPointerEvent()
    Log.d("zzx", "x:${event.changes[0].position.x}, y:${event.changes[0].position.y}")
}

事件分发与事件消费

awaitPointerEvent 存在一个可选参数 PointerEventPass,用来定制手势事件分发顺序的:

kotlin
suspend fun awaitPointerEvent(
    pass: PointerEventPass = PointerEventPass.Main
): PointerEvent

有三个值,让我们来决定手势的处理阶段:

  1. Initial 阶段:自上而下的分发手势事件
  2. Main 阶段:自下而上地分发手势事件
  3. Final 阶段:自上而下的分发手势事件

Initial 阶段,手势事件会在所有使用 Initial 参数的组件间自上而下地完成首次分发