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,能帮助我们更深入掌控交互逻辑,实现丰富、灵动的交互体验。

相关推荐
alexhilton17 小时前
初探Compose中的着色器RuntimeShader
android·kotlin·android jetpack
小白马丶1 天前
Jetpack Compose开发框架搭建
android·前端·android jetpack
Wgllss1 天前
完整案例:Kotlin+Compose+Multiplatform跨平台之桌面端实现(二)
android·架构·android jetpack
刘龙超2 天前
如何应对 Android 面试官 -> 运用 Jetpack 写一个音乐播放器(二)音乐列表
android jetpack
Wgllss3 天前
完整案例:Kotlin+Compose+Multiplatform跨平台之桌面端实现(一)
android·架构·android jetpack
alexhilton4 天前
学会说不!让你彻底学会Kotlin Flow的取消机制
android·kotlin·android jetpack
_一条咸鱼_6 天前
Android Runtime冷启动与热启动差异源码级分析(99)
android·面试·android jetpack
柿蒂6 天前
一次Android下载优化,CDN消耗占比从50+%到1%
android·android jetpack
bytebeats7 天前
Android 开发者的 Jetpack Compose 学习路线图
android·android jetpack