了解手势

在应用中处理手势操作时,理解一些重要的术语和概念至关重要。本页解释了指针、指针事件和手势等术语,并介绍了不同级别的手势抽象。此外,还深入探讨了事件的消费和传播。

定义

为了理解本页上的各种概念,您需要了解一些使用的术语。

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

不同的抽象级别

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

一般来说,应基于提供所需功能的最高抽象级别进行构建。这样,您就可以从该层包含的最佳实践中受益。例如,Button 包含比 clickable 更多的语义信息(用于辅助功能),而 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 中的辅助功能

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

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

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

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

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

并非所有手势都通过现成的(out-of-the-box)手势修饰符实现。例如,无法使用修饰符来响应长按后的拖动、Ctrl+单击或三指轻触。相反,您可以编写自己的手势处理程序来识别这些自定义手势。您可以使用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会处理该手势并导航到文章。就指针输入而言,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
            }
        }
    }
}

事件传播

如前所述,指针更改将传递到其命中的每个可组合项。但是,如果存在多个这样的可组合项,则事件以何种顺序传播?如果您采用上一节中的示例,此 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.

指针事件在三个“传递”期间通过这些可组合项中的每一个三次。

  • 在**初始阶段 (Initial pass)**,事件从UI树的顶部向下流动。此流程允许父元素在子元素处理事件之前拦截事件。例如,工具提示 (tooltips) 需要拦截长按事件 (intercept a long-press),而不是将其传递给子元素。在我们的示例中,ListItemButton 之前接收事件。
  • 在**主要阶段 (Main pass)**,事件从UI树的叶子节点向上流动到UI树的根节点。此阶段是通常处理手势的阶段,也是监听事件时的默认阶段。在此阶段处理手势意味着叶子节点优先于其父节点,这对于大多数手势来说是最符合逻辑的行为。在我们的示例中,ButtonListItem 之前接收事件。
  • 在**最终阶段 (Final pass)**,事件再次从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 中手势的更多信息: