了解手势

在应用中进行手势处理时,了解几个术语和概念很重要。本页介绍了“指针”、“指针事件”和“手势”这几个术语,并介绍了各种手势的不同抽象级别。此外,本文还深入探讨了事件的使用和传播。

定义

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

  • 指针:可用于与应用交互的实体对象。对于移动设备,最常见的指针是手指与触摸屏互动。或者,您也可以使用触控笔来替换手指。 对于大屏幕,您可以使用鼠标或触控板与显示屏间接互动。输入设备必须能够“指向”某个坐标,才会被视为指针,因此,举例来说,键盘不能被视为指针。在 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 来处理点按手势,或者通过应用 verticalScrollColumn 处理垂直滚动。

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

一般来说,与自定义手势处理相比,最好使用开箱即用的手势修饰符。除了纯指针事件处理之外,修饰符还添加了更多功能。例如,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 方法未能为您的用例提供足够精细的控制,您可以监听原始事件并对其执行计算。这些辅助方法包括 calculateCentroidcalculateCentroidSizecalculatePancalculateRotationcalculateZoom

事件调度和点击测试

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

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

由鼠标或触控笔悬停时触发的悬停事件不属于此处定义的规则。悬停事件会发送给用户点击的任意可组合项。因此,当用户将指针从一个可组合项的边界悬停在下一个可组合项的边界上时,事件会发送到新的可组合项,而不是将事件发送到第一个可组合项。

事件消耗

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

带有图片的列表项、包含两个文本的 Column 和一个 Button。

当用户点按书签按钮时,该按钮的 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
            }
        }
    }
}

事件传播

如前所述,指针更改会传递到其命中的每个可组合项。但是,如果存在多个此类可组合项,事件会按什么顺序传播?如果采用上一部分中的示例,此界面会转换为以下界面树,其中只有 ListItemButton 会响应指针事件:

树结构。顶层是 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 中的手势: