手势处理
更新: 1/31/2026 字数: 0 字 时长: 0 分钟
常用手势处理 Modifier
Clickable 点击
Box(modifier = Modifier
.size(200.dp)
.background(Color.Green)
.clickable(enabled = enableState) {
// ...
})CombinedClickable 复合点击
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
).combinedClickable(
enabled = enableState,
onLongClick = {
Log.d("zzx", "长按点击")
},
onDoubleClick = {
Log.d("zzx", "双击")
},
onClick = {
Log.d("zzx","单击")
}
))Draggable 拖动
Draggable 修饰符允许开发者监听 UI 组件的拖动手势偏移量,Draggable 修饰符只能监听垂直方向或水平方向的偏移。
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 完成一个滑块拖动:
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 修饰符之前执行。
Box(
Modifier
.size(boxSlideLengthDp)
.draggable(
orientation = Orientation.Horizontal,
state = draggableState
)
.offset {
IntOffset(offsetX.roundToInt(), 0)
}
.background(Color.DarkGray)
)如果写成上面那样,就会发生视觉上正常偏移了,但是可拖拽部分仍未变。
Transformable 多点触控
@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 水平滚动
fun Modifier.horizontalScroll(
state: ScrollState,
enabled: Boolean = true,
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
)必选的只有 state,可以使用 rememberScrollState 快速创建一个 scrollState 实例并传入。
verticalScroll 垂直滚动
与 horizontalScroll 同理。
scrollable 修饰符
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 参数来指定组件初始滚动位置的。
下面来实现一个横向滚动的组件。
@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 修饰符进行封装实现的。
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
用来设置更细粒度的点击监听回调。
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:监听垂直拖动手势。
以第一个为例:
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:
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
可以获取到双指拖动,缩放与旋转手势操作中更具体的手势信息,例如重心。
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)- panZoomLock: 当拖动或缩放手势时是否支持旋转
- onGesture: 当拖动,缩放或旋转手势发生时回调
很简单,根据手势信息更新状态就可以了。
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 方法保证每一轮手势处理逻辑的一致性。
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 会返回当前监听到的所有手势交互信息。
awaitEachGesture {
var event = awaitPointerEvent()
Log.d("zzx", "x:${event.changes[0].position.x}, y:${event.changes[0].position.y}")
}事件分发与事件消费
awaitPointerEvent 存在一个可选参数 PointerEventPass,用来定制手势事件分发顺序的:
suspend fun awaitPointerEvent(
pass: PointerEventPass = PointerEventPass.Main
): PointerEvent有三个值,让我们来决定手势的处理阶段:
- Initial 阶段:自上而下的分发手势事件
- Main 阶段:自下而上地分发手势事件
- Final 阶段:自上而下的分发手势事件
Initial 阶段,手势事件会在所有使用 Initial 参数的组件间自上而下地完成首次分发
