了解手势

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

定义

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

  • 指针:您可以用来与应用程序交互的物理对象。对于移动设备,最常见的指针是您的手指与触摸屏的交互。或者,您可以使用触控笔来代替您的手指。对于大屏幕,您可以使用鼠标或触控板间接地与显示器进行交互。输入设备必须能够“指向”坐标才能被视为指针,因此键盘不能被视为指针。在 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 中的辅助功能

使用修饰符将特定手势添加到任意可组合物

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

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

作为一项规则,优先考虑开箱即用的手势修饰符而不是自定义手势处理。修饰符在纯指针事件处理的基础上增加了更多功能。例如,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 中手势的更多信息