了解手势

有几个术语和概念需要了解 。本页介绍了 指针、指针事件和手势,并引入了不同的抽象化机制, 以及手势操作。还更深入地介绍了事件消费 传播。

定义

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

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

不同的抽象级别

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

一般而言,应在提供 所需的全部功能这样,您就能从本演示文稿中介绍的最佳做法中 所处的位置。例如,Button 包含更多语义信息, 与 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 中的无障碍功能,请参阅无障碍功能 写邮件

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

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

我们提供许多修饰符来处理不同类型的手势:

通常,相较于自定义手势处理,建议选择开箱即用的手势修饰符。 除了纯指针事件处理之外,这些修饰符还添加了更多功能。 例如,clickable 修饰符不仅增加了对按下动作的检测和 但也会添加语义信息、关于互动的视觉指示、 悬停、焦点和键盘支持。您可以查看源代码 (共 clickable 页),了解相应功能 正在添加。

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

并非所有手势都是通过开箱即用的手势修饰符实现的。对于 例如,您不可以使用修饰符来响应长按、 按住 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 修饰符。事件 调度的工作原理如下:

  • 指针事件会分派给一个可组合项层次结构。 新指针触发其第一个指针事件时,系统开始点击测试 “有效”可组合项。可组合项符合以下条件时即被视为符合条件 指针输入处理功能。界面顶部的点击测试流程 放在底部。可组合项是“命中”当指针事件发生时 该可组合项的边界内。此过程会生成一系列 可组合项
  • 默认情况下,如果多个符合条件的可组合项位于同一级别上, 则只有 Z-index 最高的可组合项才是“hit”。对于 例如,当您向 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
            }
        }
    }
}

事件传播

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

树形结构。顶层是 ListItem,第二层包含图片、列和按钮,列拆分为两个文本。ListItem 和 Button 处于突出显示状态。

在三次运行期间,指针事件会流经每个可组合项三次 "通过":

  • 初始传递中,事件从界面树顶部流向 底部。此流程允许父级在子级之前拦截事件 消耗掉。例如,提示需要拦截 长按,而不是将其传递给子级。在我们的 例如,ListItem 会在 Button 之前收到事件。
  • 主通道中,事件从界面树的叶节点流向 是界面树的根目录这个阶段是你通常使用手势的阶段, 在监听事件时默认传递。在此卡券中处理手势 这意味着叶节点优先于其父节点,即 大多数手势的逻辑行为。在我们的示例中,Button 收到 在 ListItem 之前触发事件。
  • Final Pass 中,事件从界面顶部再次流动 传递给叶节点。该流程允许堆栈中较高级别的元素 响应其父级使用的事件。例如,按钮移除 其涟漪指示。

从直观上看,事件流可表示如下:

使用输入更改后,这些信息就会从 下一个步骤:

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

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 中的手势,请参阅以下文档 资源: