Compose 手势处理全面解析

背景

在 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 实现,下面分析其各函数的重写方式。

  1. 实现 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
}
  1. 实现 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
}
  1. 实现 onPreFling

松手时,判断是否需要刷新,如果需要刷新展示一个吸附的 Loading,否则,指示器回弹消失。

  1. 实现 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 的区别

  1. 无默认的视觉反馈(需自行实现波纹或其他效果)
  2. 提供更细粒度的位置信息(Offset 为相对于组件的坐标)
  3. 不自动支持无障碍服务

因此能让我们根据需求进行更灵活的上层定制。

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,事件分发顺序为:第一层 → 第三层 → 第四层 → 第二层,如下图所示:

点击中心白色区域时的事件传递流程:

  1. PointerEventPass.Initial 阶段(自上而下):

    1. 第一层触发 awaitPointerEvent(PointerEventPass.Initial),打印 first layer
    2. 第二层跳过(监听的是 Final 阶段)
    3. 第三层触发 awaitPointerEvent(PointerEventPass.Initial), 打印 third layer
    4. 第四层跳过(监听的是 Main 阶段)
  2. PointerEventPass.Main 阶段(自下而上):

    1. 第四层触发 awaitPointerEvent(PointerEventPass.Main),打印 fourth layer
    2. 第三层跳过(监听的是 Initial 阶段)
    3. 第二层跳过(监听的是 Final 阶段)
    4. 第一层跳过(监听的是 Initial 阶段)
  3. PointerEventPass.Final 阶段(自上而下):

    1. 第一层跳过(监听的是 Initial 阶段)
    2. 第二层触发 awaitPointerEvent(PointerEventPass.Final), 打印 second layer
    3. 第三层跳过(监听的是 Initial 阶段)
    4. 第四层跳过(监听的是 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,能帮助我们更深入掌控交互逻辑,实现丰富、灵动的交互体验。

相关推荐
雨白1 天前
Hilt 入门指南:从 DI 原理到核心用法
android·android jetpack
我命由我123451 天前
Android 开发 - Android JNI 开发关键要点
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
alexhilton2 天前
在Jetpack Compose中创建CRT屏幕效果
android·kotlin·android jetpack
峰哥的Android进阶之路2 天前
viewModel机制及原理总结
android jetpack
我命由我123454 天前
Android WebView - loadUrl 方法的长度限制
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
Coffeeee4 天前
面试被问到Compose的副作用不会,只怪我没好好学
android·kotlin·android jetpack
Frank_HarmonyOS7 天前
Android APP 的压力测试与优化
android jetpack
QING6188 天前
Jetpack Compose 条件布局与 Layout 内在测量详解
android·kotlin·android jetpack
Lei活在当下8 天前
【现代 Android APP 架构】09. 聊一聊依赖注入在 Android 开发中的应用
java·架构·android jetpack
bqliang8 天前
Jetpack Navigation 3:领航未来
android·android studio·android jetpack