了解手势

在应用中处理手势时,请务必了解几个术语和概念。本页介绍了“指针”“指针事件”和“手势”等术语,并介绍了手势的不同抽象级别。此外,还深入探讨了事件的使用和传播。

定义

如需了解本页面上的各种概念,您需要了解所用的一些术语:

  • 指针:可用于与应用互动的实体对象。 对于移动设备,最常见的指针是与触摸屏互动的手指。或者,您也可以使用触控笔代替手指。 对于大屏设备,您可以使用鼠标或触控板与显示屏进行间接互动。输入设备必须能够“指向”某个坐标,才能被视为指针,因此键盘等设备不能被视为指针。在 Compose 中,指针类型包含在使用 PointerType 的指针更改中。
  • 指针事件:描述一个或多个指针在给定时间与应用的低级别互动。任何指针互动(例如将手指放在屏幕上或拖动鼠标)都会触发事件。在 Compose 中,此类事件的所有相关信息都包含在 PointerEvent 类中。
  • 手势:可解读为单个操作的一系列指针事件。例如,点按手势可以被视为一个按下事件后跟一个抬起事件的序列。许多应用都会使用一些常见手势,例如点按、拖动或转换,但您也可以根据需要创建自己的自定义手势。

不同的抽象层级

Jetpack Compose 提供了不同级别的抽象来处理手势。 顶层是组件支持。Button 等可组合项会自动包含手势支持。如需为自定义组件添加手势支持,您可以向任意可组合项添加手势修饰符,例如 clickable。最后,如果您需要自定义手势,可以使用 pointerInput 修饰符。

一般来讲,最好基于能提供您所需功能的最高抽象级别进行构建。这样,您就可以从该层中包含的最佳实践中受益。例如,Button 包含的语义信息(用于 无障碍功能)比 clickable 多,而 包含的信息比原始 pointerInput 实现多。

组件支持

Compose 中的许多开箱即用组件都包含某种内部手势处理。例如,LazyColumn 通过滚动其内容来响应拖动手势,Button 在您按下时会显示涟漪效果,SwipeToDismiss 组件包含滑动逻辑,可用于关闭元素。这种手势处理方式会自动运行。

除了内部手势处理之外,许多组件还需要调用方处理手势。例如,Button 会自动检测点按操作并触发点击事件。您需要将 onClick lambda 传递给 Button,以对该手势做出响应。同样,您可以向 Slider 添加 onValueChange lambda,以响应用户拖动滑块滑块的操作。

如果符合您的用例,请首选组件中包含的手势,因为它们包含对焦点和无障碍功能的开箱即用支持,并且经过了充分测试。例如,Button 会以特殊方式进行标记,以便无障碍服务将其正确描述为按钮,而不仅仅是任何可点击的元素:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

如需详细了解 Compose 中的无障碍功能,请参阅 Compose 中的无障碍功能。

使用修饰符向任意可组合项添加特定手势

您可以将手势修饰符应用于任意可组合项,使可组合项监听手势。例如,您可以通过使通用 Box 变为 clickable 来让其处理点按手势,也可以通过应用 verticalScroll 让 Column 处理垂直滚动。

有许多修饰符可用于处理不同类型的手势:

一般来说,应优先使用开箱即用的手势修饰符,而不是自定义手势处理。 这些修饰符在纯指针事件处理的基础上添加了更多功能。 例如,clickable 修饰符不仅会添加对按压和点按的检测,还会添加语义信息、互动视觉指示、悬停、焦点和键盘支持。您可以查看 clickable 的源代码,了解如何添加该功能。

使用 pointerInput 修饰符向任意可组合项添加自定义手势

并非每个手势都是通过开箱即用的手势修饰符实现的。例如,您无法使用修饰符来响应长按后拖动、按住 Control 键点击或三指点按。您可以自行编写手势处理程序来识别这些自定义手势。您可以使用 pointerInput 修饰符创建手势处理程序,以便访问原始指针事件。

以下代码会监听原始指针事件:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

如果将此代码段拆分,其核心组件包括:

  • pointerInput 修饰符。您需要向其传递一个或多个键。当其中一个键的值发生变化时,修饰符内容 lambda 会重新执行。该示例向可组合项传递了一个可选过滤条件。如果该过滤条件的值发生变化,则应重新执行指针事件处理脚本,以确保记录正确的事件。
  • awaitPointerEventScope 会创建一个协程作用域,可用于等待指针事件。
  • awaitPointerEvent 会挂起协程,直到发生下一个指针事件。

虽然监听原始输入事件的功能很强大,但基于这些原始数据编写自定义手势也很复杂。为了简化自定义手势的创建,我们提供了许多实用方法。

检测完整手势

您可以监听特定手势的发生并做出适当响应,而不是处理原始指针事件。AwaitPointerEventScope 提供了用于监听以下事件的方法:

这些是顶级检测器,因此您无法在一个 pointerInput 修饰符中添加多个检测器。以下代码段仅检测点按,不检测拖动:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

在内部,detectTapGestures 方法会阻塞协程,并且永远不会到达第二个检测器。如果您需要向可组合项添加多个手势监听器,请改用单独的 pointerInput 修饰符实例:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

处理每个手势的事件

根据定义,手势以指针按下事件开始。您可以使用 awaitEachGesture 辅助方法,而不是通过每个原始事件的 while(true) 循环。当所有指针都已抬起时,awaitEachGesture 方法会重启包含块,这表示手势已完成:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

在实践中,您几乎总是希望使用 awaitEachGesture,除非您在不识别手势的情况下响应指针事件。hoverable 就是一个例子,它不会响应指针按下或抬起事件,而只需要知道指针何时进入或退出其边界。

等待特定事件或子手势

有一组方法可帮助识别手势的常见部分:

对多接触点事件应用计算

当用户使用多个指针执行多点触控手势时,很难根据原始值了解所需的转换。如果 transformable 修饰符或 detectTransformGestures 方法无法为您的用例提供足够精细的控制,您可以监听原始事件并对这些事件应用计算。这些辅助方法包括 calculateCentroid、calculateCentroidSize、calculatePan、calculateRotation 和 calculateZoom。

事件分派和点击测试

并非每个指针事件都会发送到每个 pointerInput 修饰符。事件分派的工作原理如下:

  • 指针事件会分派到可组合项层次结构。当新指针触发其第一个指针事件时,系统会开始对“符合条件”的可组合项进行点击测试。如果可组合项具有指针输入处理功能,则会被视为符合条件。点击测试从界面树的顶部流向底部。当指针事件发生在可组合项的边界内时,该可组合项即被“命中”。此过程会生成一个可组合项链,其中包含点击测试结果为正的可组合项。
  • 默认情况下,如果树的同一级别上有多个符合条件的可组合项,则只有 z-index 最高的可组合项会被“命中”。例如,当您向 Box 添加两个重叠的 Button 可组合项时,只有在顶部绘制的可组合项才会接收任何指针事件。理论上,您可以通过创建自己的 PointerInputModifierNode 实现并将 sharePointerInputWithSiblings 设置为 true 来替换此行为。
  • 同一指针的后续事件会分派到同一可组合项链,并根据事件传播逻辑流动。系统不会再对此指针执行任何点击测试。这意味着,链中的每个可组合项都会收到该指针的所有事件,即使这些事件发生在可组合项的边界之外。不在链中的可组合项永远不会收到指针事件,即使指针位于其边界内也是如此。

由鼠标或触控笔悬停触发的悬停事件不适用此处定义的规则。悬停事件会发送到它们命中的任何可组合项。因此,当用户将指针从一个可组合项的边界悬停到下一个可组合项时,系统不会将事件发送到第一个可组合项,而是将事件发送到新的可组合项。

事件使用

如果多个可组合项都分配了手势处理程序,这些处理程序不应发生冲突。例如,请看以下界面:

包含一个图片、一个包含两个文本的列和一个按钮的列表项。

当用户点按书签按钮时,该按钮的 onClick lambda 会处理该手势。当用户点按列表项的任何其他部分时,ListItem 会处理该手势并导航到文章。就指针输入而言,Button 必须使用此事件,以便其父级知道不再对此事件做出响应。开箱即用组件和常见手势修饰符中包含的手势都包含这种使用行为,但如果您要编写自己的自定义手势,则必须手动使用事件。您可以使用 PointerInputChange.consume 方法执行此操作:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

使用事件不会阻止事件传播到其他可组合项。可组合项需要改为明确忽略已使用的事件。编写自定义手势时,您应检查事件是否已被其他元素使用:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

事件传播

如前所述,指针变化会传递给它命中的每个可组合项。 但如果存在多个此类可组合项,事件会按什么顺序传播?如果以上一节中的示例为例,此界面会转换为以下界面树,其中只有 ListItem 和 Button 会响应指针事件:

树状结构。顶层是 ListItem,第二层包含 Image、Column 和 Button,Column 分为两个 Text。ListItem 和 Button 突出显示。

指针事件会在三个“传递”期间流经每个可组合项三次:

  • 在初始传递中,事件从界面树的顶部流向底部。此流程允许父级在子级使用事件之前拦截该事件。例如,提示需要拦截长按,而不是将其传递给其子级。在我们的示例中,ListItem 会在 Button 之前收到事件。
  • 在主传递中,事件会从界面树的叶节点向上流向界面树的根节点。此阶段通常是您使用手势的阶段,也是监听事件时的默认传递阶段。在此传递中处理手势意味着叶节点优先于其父节点,这是大多数手势最合乎逻辑的行为。在我们的示例中,Button 会在 ListItem 之前收到事件。
  • 在最终传递中,事件会再次从界面树的顶部流向叶节点。此流程允许堆栈中较高的元素响应其父级对事件的使用。例如,当按下操作变为拖动其可滚动父级时,按钮会移除其涟漪指示。

从视觉上看,事件流可以表示如下:

输入更改一经使用,此信息就会从流程中的该点开始向下传递:

在代码中,您可以指定您感兴趣的卡券:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

在此代码段中,每个 await 方法调用都会返回相同的事件,但有关消耗的数据可能已发生变化。

测试手势

在测试方法中,您可以使用 performTouchInput 方法手动发送指针事件。这样,您就可以执行更高级别的完整手势(例如双指张合或长按)或低级别手势(例如将光标移动一定数量的像素):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

如需查看更多示例,请参阅 performTouchInput 文档。

了解详情

您可以通过以下资源详细了解 Jetpack Compose 中的手势: