了解手势

在应用中处理手势时,需要理解几个重要的术语和概念。本页解释了指针、指针事件和手势等术语,并介绍了手势的不同抽象级别。它还深入探讨了事件的消耗和传播。

定义

要理解本页的各种概念,您需要理解所使用的一些术语

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

不同级别的抽象

Jetpack Compose 提供了不同级别的抽象来处理手势。最高级别是组件支持。像 Button 这样的可组合项会自动包含手势支持。要为自定义组件添加手势支持,您可以将手势修饰符(如 clickable)添加到任意可组合项中。最后,如果您需要自定义手势,可以使用 pointerInput 修饰符。

通常,请在提供您所需功能的最高抽象级别上进行构建。这样,您将受益于该层中包含的最佳实践。例如,Button 包含比 clickable 更多的语义信息(用于无障碍功能),而 clickable 又包含比原始 pointerInput 实现更多的信息。

组件支持

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

除了内部手势处理之外,许多组件还需要调用者处理手势。例如,Button 会自动检测轻触并触发点击事件。您将 onClick lambda 传递给 Button 以响应手势。类似地,您可以将 onValueChange lambda 添加到 Slider 以响应用户拖动滑块手柄。

如果适合您的用例,请优先使用组件中包含的手势,因为它们包含了对焦点和无障碍功能的开箱即用支持,并且经过了充分测试。例如,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 中的无障碍功能

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

您可以将手势修饰符应用于任何任意可组合项,以使该可组合项监听手势。例如,您可以通过使其clickable来让通用的 Box 处理轻触手势,或者通过应用 verticalScroll 来让 Column 处理垂直滚动。

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

通常,优先选择开箱即用的手势修饰符,而不是自定义手势处理。这些修饰符在纯指针事件处理之上添加了更多功能。例如,clickable 修饰符不仅添加了对按压和轻触的检测,还添加了语义信息、交互时的视觉指示、悬停、焦点和键盘支持。您可以查看 clickable 的源代码以了解如何添加这些功能。

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

并非每个手势都通过开箱即用的手势修饰符实现。例如,您不能使用修饰符来响应长按后的拖动、组合键点击或三指轻触。相反,您可以编写自己的手势处理程序来识别这些自定义手势。您可以使用 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 方法无法为您的用例提供足够的精细控制,您可以监听原始事件并对其应用计算。这些辅助方法包括 calculateCentroidcalculateCentroidSizecalculatePancalculateRotationcalculateZoom

事件调度和命中测试

并非所有指针事件都发送到每个 pointerInput 修饰符。事件调度工作方式如下

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

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

事件消耗

当有多个可组合项分配了手势处理程序时,这些处理程序不应冲突。例如,看看这个 UI

List item with an Image, a Column with two texts, and a Button.

当用户轻触书签按钮时,按钮的 onClick lambda 会处理该手势。当用户轻触列表项的任何其他部分时,ListItem 会处理该手势并导航到文章。就指针输入而言,按钮必须消耗此事件,以便其父级知道不再对此事件做出反应。开箱即用组件中包含的手势和常见的手势修饰符都包含此消耗行为,但如果您正在编写自己的自定义手势,则必须手动消耗事件。您可以使用 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
            }
        }
    }
}

事件传播

如前所述,指针更改会传递到它命中的每个可组合项。但是,如果存在多个这样的可组合项,事件以什么顺序传播?如果以上一节的示例为例,此 UI 转换为以下 UI 树,其中只有 ListItemButton 响应指针事件

Tree structure. Top layer is ListItem, second layer has Image, Column, and Button, and the Column splits out into two Texts. ListItem and Button are highlighted.

指针事件分三次“传递”流经每个可组合项

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

在视觉上,事件流可以表示如下

一旦输入更改被消耗,此信息将从流程中的该点开始传递

在代码中,您可以指定您感兴趣的传递

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 中手势的更多信息