背景
在 Android 开发中,优质的交互体验是影响用户满意度的关键因素之一。Jetpack Compose 作为现代化 UI 工具,为开发者提供了强大灵活的手势处理能力。本文将全面拆解 Compose 的手势处理机制,从常用修饰符到低层 API,再到自定义实现,助你打造丝滑、精准的用户交互体验。
一 、常用的手势处理 Modifier
Compose 提供了一系列开箱即用的手势修饰符,以下是常用的几种:
最佳实践:
在处理手势时,应将手势处理修饰符尽可能放到 Modifier 末尾,从而可以避免产生不可预期的行为。开发时可养成 "布局( size/padding )→ 基础交互( clickable )→ 自定义手势( pointerInput )" 的修饰符书写习惯,让组件交互既灵活又可控,远离因顺序混乱导致的 "不可预期行为",为用户打造丝滑、精准的手势体验。
scss
// 危险写法:手势修饰符前置
Modifier
.pointerInput(Unit) { detectTapGestures { /* 点击逻辑 */ } }
.padding(20.dp)
.size(100.dp)
1.1 Clickable 点击
Clickable 修饰符用来监听组件的点击操作,并且当点击事件发生时,会为被点击的组件施加一个波纹涟漪效果动画的蒙层。
kotlin
fun Modifier.clickable(
enabled: Boolean = true, // 是否开启点击
onClickLabel: String? = null, // 无障碍标签
role: Role? = null, // 用于指定组件的"语义角色",用于无障碍服务。
interactionSource: MutableInteractionSource? = null,
onClick: () -> Unit, // 单击回调
)
Clickable 修饰符使用起来非常简单,在绝大多数场景下,只需要传入 onClick 回调即可,用于处理点击事件。当然也可以将 enabled 参数设置为一个可变状态,通过状态来动态控制启用点击监听。
如果想去掉点击效果,interactionSource 设置为 remember { MutableInteractionSource () } 即可 。
1.2 CombinedClickable 复合点击
对于长按点击、双击等复合类点击手势,可以使用 CombinedClickable 修饰符来实现手势监听。与 Clickable 修饰符一样,其同样也可以监听单击手势,并且也会为被点击的组件施加一个波纹涟漪效果动画的蒙层。
kotlin
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null, // 长按回调
onDoubleClick: (() -> Unit)? = null, // 双击回调
hapticFeedbackEnabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
onClick: () -> Unit, // 单击回调
)
使用起来也很简单,我们为需要监听的点击事件设置监听回调就可以了。
1.3 Draggable 拖动
Draggable 修饰符允许开发者监听 UI 组件的拖动手势偏移量,并可根据偏移量定制 UI 的拖动交互效果。但是需要注意的是 Draggable 修饰符仅能监听垂直或水平单一方向的偏移。如果想实现任意方向的偏移监听,则需借助更底层的 detectDragGestures 函数。
kotlin
@Stable
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:监听的拖动手势方向,只能是水平方向或垂直方向。
使用示例:
kotlin
@Composable
fun DraggableDemo(modifier: Modifier = Modifier) {
var offsetX by remember { mutableFloatStateOf(0f) }
Image(
painter = painterResource(R.drawable.icon_test),
contentDescription = "",
modifier = modifier
.padding(top = 30.dp, start = 30.dp)
.size(50.dp)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
state = rememberDraggableState { deltaX ->
offsetX += deltaX
},
orientation = Orientation.Horizontal
)
)
}

1.4 Transformable 多点触控
双指拖动、缩放与旋转手势在日常开发中十分常见,尤其适用于图片预览、编辑等场景。Transformable 修饰符能让开发者轻松监听组件的双指拖动、缩放及旋转手势事件,并通过定制 UI 动画实现完整的交互效果。其定义如下:
kotlin
fun Modifier.transformable(
state: TransformableState,
// 是否可以执行平移手势
canPan: (Offset) -> Boolean,
// 为true时,在发生双指缩放或拖动时,不会同时监听用户的旋转手势信息。
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true,
)
可以使用 rememberTransformableState 来创建一个 transformableState 状态。
使用示例:
scss
@Composable
fun TransformableDemo(modifier: Modifier = Modifier) {
var offset by remember { mutableStateOf(Offset.Zero) }
var rotationAngle by remember { mutableFloatStateOf(0f) }
var scale by remember { mutableFloatStateOf(1f) }
// TransformableState用于处理手势操作,例如缩放(zoomChange)、平移(panChange)和旋转(rotationChange)
val transformableState = rememberTransformableState { zoomChange, panChange, rotationChange ->
scale *= zoomChange
offset += panChange
rotationAngle += rotationChange
}
Image(
painter = painterResource(R.drawable.icon_test),
contentDescription = "",
modifier = modifier
.padding(top = 30.dp, start = 30.dp)
.size(140.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.rotate(rotationAngle)
.scale(scale)
.transformable(
state = transformableState,
lockRotationOnZoomPan = false
)
)
}
效果如下:
1.5 Scrollable 滚动
当视图组件的宽或高超出屏幕边界时,我们希望能通过滑动查看更多内容。对于长列表场景可以使用 LazyColumn 与 LazyRow 组件实现;而对于一般组件,则可借助 Scrollable 系列修饰符赋予其滚动能力。
Scrollable 系列修饰符包括 horizontalScroll、verticalScroll 和 scrollable 。其中 horizontalScroll 与 verticalScroll 均基于 scrollable 实现,因此下文将重点介绍 scrollable 的用法。
kotlin
@Stable
fun Modifier.scrollable(
state: ScrollableState, // 滚动状态
orientation: Orientation, // 滚动方向,只支持 Horizontal 与 Vertical
overscrollEffect: OverscrollEffect?,
enabled: Boolean = true,
reverseDirection: Boolean = false, // 反转滚动方向
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null,
bringIntoViewSpec: BringIntoViewSpec? = null,
)
接下来,基于 scrollable 修饰符的滚动监听能力自己实现 horizontalScroll 修饰符。这里仍然为 Row 组件增加横向滚动的能力,利用 offset 修饰符使组件内容偏移。由于初始位置为 Row 的左侧首部,我们希望能够在初始位置手指向左滑动查看 Row 组件右部超出屏幕的内容,所以这里需要将 reverseDirection 参数设置为 true。
ini
@Composable
fun ScrollableDemo(modifier: Modifier = Modifier) {
val scrollState = rememberScrollState()
Row(
modifier = modifier
.offset {
// 滚动位置增大时应该向左偏移,所以此时应该设置为负数
val offsetX = -scrollState.value.toDp().roundToPx()
IntOffset(offsetX, 0)
}
.scrollable(
state = scrollState,
orientation = Orientation.Horizontal,
reverseDirection = true
)
) {
repeat(20) { index ->
Text(
text = "第${index}个Item",
modifier = Modifier
.padding(start = 10.dp) // 添加space
.background(Color.Red)
.padding(10.dp)
)
}
}
}
上述代码在预览时就有问题;而且在左滑时,原本位于屏幕外的内容进入屏幕时没有背景,文字也错乱。
这是因为 Row 组件的默认测量策略导致超出屏幕的子组件宽度测量结果为零,此时就需要使用 layout 修饰符自己来定制组件布局了。我们需要创建一个新的约束,用于测量组件的真实宽度,主动设置组件所应占有的宽高尺寸,并根据组件的滚动偏移量来摆放组件内容。
ini
fun Modifier.customHorizontalScroll(scrollState: ScrollState): Modifier = composed {
val coroutineScope = rememberCoroutineScope()
// 测量内容宽度和容器宽度
var contentWidth by remember { mutableIntStateOf(0) }
var containerWidth by remember { mutableIntStateOf(0) }
// 派生状态:最大可滚动距离
val maxScroll by remember {
derivedStateOf {
(contentWidth - containerWidth).coerceAtLeast(0)
}
}
// 内部维护 offset,用于控制实际 layout 偏移
val scrollOffset = remember { mutableFloatStateOf(0f) }
// 构建 scrollableState,驱动 scrollOffset + 同步外部 scrollState
val scrollableState = remember {
ScrollableState { delta ->
// 不能直接将 delta 用于偏移,防止超出边界。
val raw = scrollOffset.floatValue - delta
val newValue = raw.coerceIn(0f, maxScroll.toFloat())
// 实际消费的偏移
val consumed = scrollOffset.floatValue - newValue
scrollOffset.floatValue = newValue
// 同步到外部 scrollState
coroutineScope.launch {
scrollState.scrollTo(newValue.toInt())
}
consumed
}
}
this
.scrollable(
state = scrollableState,
orientation = Orientation.Horizontal,
flingBehavior = ScrollableDefaults.flingBehavior()
)
.layout { measurable, constraints ->
// 测量内容真实宽度(不受容器限制)
val placeable = measurable.measure(
constraints.copy(maxWidth = Constraints.Infinity)
)
contentWidth = placeable.width
containerWidth = constraints.maxWidth
// 计算实际偏移并摆放内容
val scroll = scrollOffset.floatValue.coerceIn(0f, maxScroll.toFloat())
layout(containerWidth, placeable.height) {
placeable.placeRelative(x = -scroll.toInt(), y = 0)
}
}
}
@Composable
fun ScrollableDemo(
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Row(
modifier = modifier.customHorizontalScroll(scrollState)
) {
repeat(20) { index ->
Text(
text = "第${index}个Item",
modifier = Modifier
.padding(start = 10.dp)
.background(Color.Red)
.padding(10.dp)
)
}
}
}
效果如下:
1.6 NestedScroll 嵌套滚动
开发中常需处理嵌套滑动以解决手势冲突,即协调父子 View 交互逻辑。在 View 体系中,可重写 ViewGroup 的 onInterceptTouchEvent 定制处理,但较麻烦,通常直接用 NestedScrollView;而在 Compose 中,官方提供的 nestedScroll 修饰符专门用于处理嵌套滑动手势,也能让父组件劫持消费子组件触发的滑动手势。
kotlin
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null,
)
- NestedScrollConnection:包含了嵌套滑动手势处理的核心逻辑,通过内部回调可以在子布局获得滑动事件前,预先消费掉部分或全部手势偏移量,当然也可以获取子布局消费后剩下的手势偏移量。
- NestedScrollDispatcher:包含用于父布局的 NestedScrollConnection ,可以使用包含的 dispatch* 系列函数动态控制组件完成滑动。
1.6.1 NestedScrollConnection 的作用
NestedScrollConnection提供了4个回调函数:onPreScroll、onPostScroll、onPreFling 与 onPostFling。
kotlin
@JvmDefaultWithCompatibility
interface NestedScrollConnection {
/**
* 预滚动阶段的事件传递。子组件在执行拖动前会调用此函数,
* 允许父组件提前消费一部分滚动偏移。
*
* @param available 当前可用于消费的滚动偏移量
* @param source 滚动事件的来源
* @return 实际消费的偏移量
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
* 子组件已消费完滚动偏移后,通知父组件还有剩余偏移量可供继续消费。
*
* @param consumed 已消费的偏移量
* @param available 当前剩余可消费的偏移量
* @param source 滚动事件的来源
* @return 实际消费的偏移量
*/
fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
Offset.Zero
/**
* 预抛掷阶段(Pre Fling)事件传递。子组件准备执行 fling 操作前会调用此函数,
* 允许父组件拦截并消费一部分初始速度。
*
* @param available 当前可供父组件消费的初始 fling 速度
* @return 实际消费的速度
*/
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
/**
* 抛掷后的事件传递阶段。子组件 fling 结束后调用此函数,
* 通知父组件还有剩余的速度可用于继续 fling。
*
* @param consumed 已消费的 fling 速度
* @param available 当前剩余可消费的 fling 速度
* @return 实际消费的 fling 速度
*/
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
1.6.2 使用 NestedScroll 实现下拉刷新
使用 NestedScroll 修饰符的关键是根据业务场景定制 NestedScrollConnection 实现,下面分析其各函数的重写方式。
- 实现 onPostScroll
手指向下滑动时,我们希望滑动手势应该首先交给子布局中的列表进行处理,如果列表已经滑到顶部,此时再交由父布局进行消费。
kotlin
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val delta = available.y
if (delta > 0 && source == NestedScrollSource.UserInput) {
...
return Offset(0f, delta)
}
return Offset.Zero
}
- 实现 onPreScroll
手指向上滑动时,希望滑动手势首先被父布局消费(减小加载指示器的偏移量),如果加载指示器还没出现,则不需要进行额外消费。
kotlin
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
if (delta < 0 && source == NestedScrollSource.UserInput) {
// 指示器展示时,由父布局先消费
return if (offset > 0) {
...
Offset(0f, delta)
} else {
Offset.Zero
}
}
return Offset.Zero
}
- 实现 onPreFling
松手时,判断是否需要刷新,如果需要刷新展示一个吸附的 Loading,否则,指示器回弹消失。
- 实现 onPostFling
markdown
1. 无需处理
说明: 即使松手时速度很慢或静止,onPreFling 与 onPostFling 都会回调,只是速度数值很小
完整代码如下:
kotlin
@Composable
fun PullToRefreshLayout(
refreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
// 刷新中的 header 高度(dp)和触发阈值(px)
val headerHeightPx = with(density) { 50.dp.toPx() }
val triggerHeightPx = with(density) { 60.dp.toPx() }
// 拖动偏移(px)
var offset by remember { mutableFloatStateOf(0f) }
// 是否下拉中
var isPulling by remember { mutableStateOf(false) }
// 回弹动画控制(px)
val animatableOffset = remember { Animatable(0f) }
// 当前用于展示的 offset(单位:px)
val currentOffsetPx = when {
refreshing -> headerHeightPx
isPulling -> offset
else -> animatableOffset.value
}
val currentOffsetDp = with(density) { currentOffsetPx.toDp() }
// 回弹逻辑
suspend fun settleOffset() {
if (refreshing) return
animatableOffset.snapTo(offset)
animatableOffset.animateTo(0f, animationSpec = spring())
offset = 0f
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (refreshing) return Offset.Zero
val delta = available.y
if (delta < 0 && source == NestedScrollSource.UserInput && offset > 0f) {
val resistance = exp(-offset / 200f) * 0.5f
offset += delta * resistance
return Offset(0f, delta)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (refreshing) return Offset.Zero
val delta = available.y
if (delta > 0 && source == NestedScrollSource.UserInput) {
isPulling = true
val resistance = exp(-offset / 200f) * 0.5f
offset += delta * resistance
return Offset(0f, delta)
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
isPulling = false
if (!refreshing && offset >= triggerHeightPx) {
onRefresh()
} else {
settleOffset()
}
return Velocity.Zero
}
}
}
// 刷新完成后触发回弹
LaunchedEffect(refreshing) {
if (!refreshing && offset > 0f) {
settleOffset()
}
}
Box(modifier = modifier.nestedScroll(nestedScrollConnection)) {
// Header 区域
Box(
modifier = Modifier
.fillMaxWidth()
.height(currentOffsetDp),
contentAlignment = Alignment.Center
) {
when {
refreshing -> CircularProgressIndicator(modifier = Modifier.size(24.dp))
isPulling -> {
val text = if (offset >= triggerHeightPx) "释放刷新" else "下拉刷新"
Text(text = text, color = Color.Gray)
}
else -> {}
}
}
// 主体内容区域
Column(
modifier = Modifier.offset(y = currentOffsetDp)
) {
content()
}
}
}
@Composable
fun PullToRefreshDemo(modifier: Modifier = Modifier) {
val refreshing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
PullToRefreshLayout(
refreshing = refreshing.value,
onRefresh = {
refreshing.value = true // 刷新开始
scope.launch {
delay(1500)
refreshing.value = false // 刷新完成
}
} ,
modifier = modifier
) {
LazyColumn {
items(20) { index ->
val bgColor = Color.hsv((index * 25f) % 360f, 0.3f, 1f)
Column(
modifier = Modifier
.fillMaxWidth()
.background(bgColor)
.padding(20.dp)
) {
Text(text = "Item $index")
}
HorizontalDivider(thickness = 1.dp, color = Color.LightGray)
}
}
}
}

二 、定制手势处理
对于复杂手势需求,我们需要深入理解 Compose 的手势处理机制。实际上,前面提到的各类手势处理修饰符,都是基于低级别 PointerInput 修饰符封装实现的。因此,掌握 PointerInput 修饰符的使用函数,不仅有助于理解高级别手势处理修饰符,还能帮助我们更好地进行上层开发,从而实现各种复杂的手势需求。
2.1 使用 PointerInput
kotlin
fun Modifier.pointerInput(vararg keys: Any?, block: PointerInputEventHandler): Modifier =
this then SuspendPointerInputElement(keys = keys, pointerInputEventHandler = block)
// 函数式接口,它只能包含一个抽象方法(可以有多个默认方法),目的是让你可以直接用 Lambda 表达式实例化它。
fun interface PointerInputEventHandler {
suspend operator fun PointerInputScope.invoke()
}
- keys: Composable 组件发生重组时,如果传入的 keys 发生了变化,则手势事件处理过程会被中断。
- block: 这个 PointerInputScope 类型作用域代码块中,便可以声明手势事件处理逻辑了。
PointerInputScope 接口中提供了所有可用的手势处理函数,通过这些函数能获取更详细的手势信息,实现更细粒度的事件处理。下面介绍其中的 GestureDetector 系列 API 函数。
2.1.1 detectTapGestures
在 PointerInputScope 中,可通过 detectTapGestures 设置更细粒度的点击监听回调。
与 clickable 的区别
- 无默认的视觉反馈(需自行实现波纹或其他效果)
- 提供更细粒度的位置信息(Offset 为相对于组件的坐标)
- 不自动支持无障碍服务
因此能让我们根据需求进行更灵活的上层定制。
kotlin
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null, // 双击时回调
onLongPress: ((Offset) -> Unit)? = null, // 长按时回调
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, // 按下时回调
onTap: ((Offset) -> Unit)? = null, // 轻触时回调
)
这几种点击事件回调存在固定的先后触发顺序,并非每次只执行其中一个,总的来说,onDoubleTap 回调前必定会先回调 2 次 onPress,而 onLongPress 与 onTap 回调前必定会回调 1 次 onPress。
使用示例:
kotlin
@Composable
fun PointerInputDemo(modifier: Modifier) {
Box(
modifier = modifier
.size(100.dp)
.background(Color.Red)
.pointerInput(Unit) {
detectTapGestures(
onTap = { offset ->
println("onTap, offset: $offset")
} ,
onDoubleTap = { offset ->
println("onDoubleTap, offset: $offset")
},
onLongPress = { offset ->
println("onLongPress, offset: $offset")
} ,
onPress = { offset ->
println("onPress, offset: $offset")
}
)
}
)
}
2.1.2 detectDragGestures
提到拖动监听,很多人首先会想到 Draggable 修饰符。作为手势处理的高层次封装,它在提供 UI 组件拖动手势监听基础能力的同时,附加了不少多特特性与限制,也隐藏了一些细粒度的手势事件回调设置。比如,Draggable 修饰符仅能监听水平或垂直单一方向的拖动手势。因此,为了更全面地监听拖动手势,Compose 提供了低级别 detectDragGestures 系列 API。
- detectDragGestures:监听任意方向的拖动手势。
- detectDragGesturesAfterLongPress:监听长按后的拖动手势。
- detectHorizontalDragGestures:监听水平拖动手势。
- detectVerticalDragGestures:监听垂直拖动手势。
这类拖动监听 API 功能相近,所需传入的参数也较为相似,可根据实际场景选择使用。使用时能定制不同时机的处理回调,下面以 detectDragGestures 为例说明。
kotlin
@OptIn(ExperimentalFoundationApi::class)
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = {} , // 拖动开始
onDragEnd: () -> Unit = {} , // 拖动结束
onDragCancel: () -> Unit = {} , // 拖动取消
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, // 拖动中
)
使用示例:
kotlin
@Composable
fun PointerInputDemo(modifier: Modifier) {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.size(100.dp)
.background(Color.Red)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
Log.d("PointerInput", "onDragStart, offset = $offset")
},
onDragEnd = {
Log.d("PointerInput", "onDragEnd")
},
onDragCancel = {
Log.d("PointerInput", "onDragCancel")
},
onDrag = { change, dragAmount ->
Log.d("PointerInput", "onDrag, dragAmount = $dragAmount, change = $change")
offset += dragAmount
}
)
}
)
}

2.1.3 detectTransformGestures
使用 detectTransformGestures 可以获取到双指拖动、缩放与旋转手势操作中更具体的手势信息。
kotlin
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
)
- panZoomLock(可选):当拖动或缩放手势发生时是否支持旋转。
- onGesture(必须):当拖动、缩放或旋转手势发生时回调。
scss
@Composable
fun PointerInputDemo(modifier: Modifier) {
var offset by remember { mutableStateOf(Offset.Zero) }
var rotationAngle by remember { mutableFloatStateOf(0f) }
var scale by remember { mutableFloatStateOf(1f) }
Box(
modifier = modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.rotate(rotationAngle)
.scale(scale)
.background(Color.Red)
.pointerInput(Unit) {
detectTransformGestures(
panZoomLock = true, // 锁定缩放和平移
onGesture = { _, pan, zoom, rotation ->
offset += pan
scale *= zoom
rotationAngle += rotation
}
)
}
)
}

2.1.4 awaitEachGesture
用于监听每一轮完整手势交互的核心函数,能保证每次手势都有清晰的开始和结束,并自动处理取消、生命周期同步等问题。每轮结束(包括取消)前,会自动执行 awaitAllPointersUp 确保用户手指已经全部抬起,并且自动绑定到组件生命周期,组件销毁即取消监听。
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
}
}
}
}
}
用法示例:
scss
@Composable
fun PointerInputDemo(modifier: Modifier) {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Red)
.pointerInput(Unit) {
awaitEachGesture {
// 1. 等待第一次手指按下
val down = awaitFirstDown()
// 2. 拖动直到所有手指抬起(系统会帮你判断这是一轮完整手势)
drag(down.id) { change ->
val delta = change.positionChange()
change.consume()
offset += delta
}
}
}
)
}
2.2 手势事件函数作用域 awaitPointerEventScope
由于手势处理在协程中完成,手势监听自然通过协程的挂起与恢复实现,这取代了传统的回调监听方式。要深入理解 Compose 手势处理,需学习更底层的挂起处理函数。PointerInputScope 允许通过 awaitPointerEventScope 函数获取 AwaitPointerEventScope 作用域,在该作用域中可使用 Compose 所有低级别手势处理挂起函数。当 awaitPointerEventScope 内的所有手势事件处理完毕后,会恢复执行并将 Lambda 中最后一行表达式的数值作为返回值返回。
首先来介绍一下最基本的手势监听挂起函数 awaitPointerEvent 。
2.2.1 事件之源 awaitPointerEvent
这个 API 之所以被称为 "事件之源",是因为上层所有手势监听 API 均基于它实现,他的作用类似于传统 View 体系中的 onTouchEvent ------ 无论是用户的按下、移动还是抬起操作,都会被统一视作手势事件;当事件发生时, awaitPointerEvent 会返回当前监听到的所有手势交互信息。
kotlin
@Composable
fun PointerInputDemo(modifier: Modifier) {
Box(
modifier = modifier
.size(100.dp)
.background(Color.Red)
.pointerInput(Unit) {
awaitEachGesture {
val event = awaitPointerEvent()
event.changes.forEachIndexed { index, change ->
Log.d("PointerInput", "index = $index, change = $change")
}
}
}
)
}
2.2.2 事件分发与事件消
实际上 awaitPointerEvent 存在着一个可选参数 PointerEventPass,这个参数实际上是用来定制手势事件分发顺序的。
kotlin
suspend fun awaitPointerEvent(pass: PointerEventPass = PointerEventPass.Main): PointerEvent
PointerEventPass 有 3 个枚举值,可以让我们来决定手势的处理阶段。在 Compose 中,手势处理共有 3 个阶段:
- Initial 阶段:事件自上而下传递,父组件优先接收。常用于拦截或抢先消费事件,类似传统 View 中的 onInterceptTouchEvent。
- Main 阶段:事件自下而上传递,子组件优先处理。主要用于组件的核心交互逻辑处理,类似于 View 中的 onTouchEvent。
- Final 阶段:事件再次自上而下传递,允许组件基于前面阶段的处理结果进行收尾处理或最终反馈。例如按钮可以根据事件是否已被消费,决定是否执行点击效果。
接下来通过一个嵌套组件的手势监听来演示事件的分发过程。当所有组件的手势监听均默认使用 Main 时,代码如下:
scss
@Composable
fun PointerInputDemo(modifier: Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(240.dp)
.background(Color.Red)
.pointerInput(Unit) {
awaitPointerEventScope {
awaitPointerEvent(PointerEventPass.Main)
Log.d("PointerInput", "first layer")
}
}
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(120.dp)
.background(Color.Blue)
.pointerInput(Unit) {
awaitPointerEventScope {
awaitPointerEvent(PointerEventPass.Main)
Log.d("PointerInput", "second layer")
}
}
) {
Box(
modifier = Modifier
.size(60.dp)
.background(Color.Green)
.pointerInput(Unit) {
awaitPointerEventScope {
awaitPointerEvent(PointerEventPass.Main)
Log.d("PointerInput", "third layer")
}
}
)
}
}
}
事件分发顺序为:第三层 → 第二层 → 第一层,如下图所示:
而如果第一层组件使用 Inital,第二层组件使用 Final,第三层组件使用 Main,则事件分发顺序为:第一层 → 第三层 → 第二层,如下图所示:
接下来换作四层嵌套来观察手势事件的分发,其中第一层与第三层使用 Initial,第二层使用 Final,第三层使用 Main,事件分发顺序为:第一层 → 第三层 → 第四层 → 第二层,如下图所示:
点击中心白色区域时的事件传递流程:
-
PointerEventPass.Initial 阶段(自上而下):
- 第一层触发 awaitPointerEvent(PointerEventPass.Initial),打印 first layer
- 第二层跳过(监听的是 Final 阶段)
- 第三层触发 awaitPointerEvent(PointerEventPass.Initial), 打印 third layer
- 第四层跳过(监听的是 Main 阶段)
-
PointerEventPass.Main 阶段(自下而上):
- 第四层触发 awaitPointerEvent(PointerEventPass.Main),打印 fourth layer
- 第三层跳过(监听的是 Initial 阶段)
- 第二层跳过(监听的是 Final 阶段)
- 第一层跳过(监听的是 Initial 阶段)
-
PointerEventPass.Final 阶段(自上而下):
- 第一层跳过(监听的是 Initial 阶段)
- 第二层触发 awaitPointerEvent(PointerEventPass.Final), 打印 second layer
- 第三层跳过(监听的是 Initial 阶段)
- 第四层跳过(监听的是 Main 阶段)
了解手势事件分发后,接下来看如何消费手势事件。我们知道 awaitPointerEvent 会返回一个 PointerEvent 实例。
kotlin
actual class PointerEvent internal actual constructor(
/**
* The changes.
*/
actual val changes: List<PointerInputChange>,
internal val internalPointerEvent: InternalPointerEvent?
) {
...
// 判断事件类型
actual var type: PointerEventType = calculatePointerEventType()
internal set
// view 体系的事件类型 与 compose 体系的事件类型映射关系
private fun calculatePointerEventType(): PointerEventType {
val motionEvent = motionEvent
if (motionEvent != null) {
return when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN -> PointerEventType.Press
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP -> PointerEventType.Release
MotionEvent.ACTION_HOVER_MOVE,
MotionEvent.ACTION_MOVE -> PointerEventType.Move
MotionEvent.ACTION_HOVER_ENTER -> PointerEventType.Enter
MotionEvent.ACTION_HOVER_EXIT -> PointerEventType.Exit
ACTION_SCROLL -> PointerEventType.Scroll
else -> PointerEventType.Unknown
}
}
// Used for testing.
changes.fastForEach {
if (it.changedToUpIgnoreConsumed()) {
return PointerEventType.Release
}
if (it.changedToDownIgnoreConsumed()) {
return PointerEventType.Press
}
}
return PointerEventType.Move
}
...
}
- changes:其中包含了一次手势交互中所有手指的交互信息。在多指操作时,利用 changes 可以轻松定制多指手势处理。
- motionEvent:实际上就是传统 View 系统中的 MotionEvent,被声明 internal ,说明官方并不希望我们直接拿来使用。
单指交互的完整信息被封装在 PointerInputChange 实例中,下面来看看它提供了哪些手势相关信息。
kotlin
@Immutable
class PointerInputChange(
val id: PointerId, // 手指标识,可以根据标识跟踪一次完整的交互手势序列
val uptimeMillis: Long, // 手势事件的时间戳
val position: Offset, // 当前手指在组件上的相对位置
val pressed: Boolean, // 手势事件是否是按下
val pressure: Float, // 压力值
val previousUptimeMillis: Long, // 上一次手势事件的时间戳
val previousPosition: Offset, // 上一次手势事件中手指在组件上的相对位置
val previousPressed: Boolean, // 上一次手势事件是否是按下
isInitiallyConsumed: Boolean, // 是否一开始就被消费
val type: PointerType = PointerType.Touch, // 事件类型(鼠标、手指)
val scrollDelta: Offset = Offset.Zero, // 滚轮滚动的变化量,通常在鼠标滚轮事件中有值(水平和垂直方向)
)
2.2.3 awaitFirstDown
awaitFirstDown 会等待第一根手指触发 ACTION_DOWN 事件,随后恢复执行并返回该按下事件。查看源码可知,其内部实现原理并不复杂。
kotlin
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true, // 是否要求事件还未被消费
pass: PointerEventPass = PointerEventPass.Main,
): PointerInputChange {
var event: PointerEvent
do {
event = awaitPointerEvent(pass)
} while (!event.isChangedToDown(requireUnconsumed))
return event.changes[0]
}
internal fun PointerEvent.isChangedToDown(
requireUnconsumed: Boolean,
onlyPrimaryMouseButton: Boolean = firstDownRefersToPrimaryMouseButtonOnly(),
): Boolean {
val onlyPrimaryButtonCausesDown =
onlyPrimaryMouseButton && changes.fastAll { it.type == PointerType.Mouse }
if (onlyPrimaryButtonCausesDown && !buttons.isPrimaryPressed) return false
return changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
}
2.2.4 awaitDragOrCancellation
awaitDragOrCancellation 用于监听指定手指(PointerId)的一次拖动事件。如果该手指已抬起、取消或其事件被消费,它将返回 null,表示拖动已中止。
2.2.5 drag
我们前面提到的 detectDragGestures,以及更上层的 Draggable 修饰符,其内部都是通过 drag 挂起函数来实现拖动监听的。从函数签名可以看出,该函数除了需要传入拖动手势的监听回调外,还需指定手指的标识信息,用于明确监听哪根手指的拖动手势。
kotlin
suspend fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit,
): Boolean {
var pointer = pointerId
while (true) {
val change = awaitDragOrCancellation(pointer) ?: return false
if (change.changedToUpIgnoreConsumed()) {
return true
}
onDrag(change)
pointer = change.id
}
}
2.2.6 awaitTouchSlopOrCancellation
awaitTouchSlopOrCancellation 用于监听指定手指(PointerId)的一次有效的拖动事件。如果该手指已抬起、取消或其事件被消费,它将返回 null,表示拖动已中止。
监听"滑动开始"的临界点 ------ 等待用户手指移动超过 TouchSlop,才开始认为是一个有效拖动行为。
三、总结
Compose 提供了从基础手势修饰符到底层手势监听的完整手势处理体系:
- 若需求简单,优先使用 clickable / draggable / scrollable 等高级封装。
- 若需更高自由度,可使用 detect*Gestures 监听原始交互。
- 若需完全自定义或处理多指操作,使用 pointerInput + awaitEachGesture 是最佳选择。
理解 PointerInputScope 与 AwaitPointerEventScope,能帮助我们更深入掌控交互逻辑,实现丰富、灵动的交互体验。