在应用中处理手势时,需要理解几个重要的术语和概念。本页解释了指针、指针事件和手势等术语,并介绍了手势的不同抽象级别。它还深入探讨了事件的消耗和传播。
定义
要理解本页的各种概念,您需要理解所使用的一些术语
- 指针:可用于与应用交互的物理对象。对于移动设备,最常见的指针是您的手指与触摸屏交互。此外,您可以使用手写笔代替手指。对于大屏幕,您可以使用鼠标或触控板间接与显示器交互。输入设备必须能够“指向”某个坐标才被视为指针,因此键盘(例如)不能被视为指针。在 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
、combinedClickable
、selectable
、toggleable
和triStateToggleable
修饰符来处理轻触和按压。 - 使用
horizontalScroll
、verticalScroll
和更通用的scrollable
修饰符来处理滚动。 - 使用
draggable
和swipeable
修饰符来处理拖动。 - 使用
transformable
修饰符来处理多点触控手势,例如平移、旋转和缩放。
通常,优先选择开箱即用的手势修饰符,而不是自定义手势处理。这些修饰符在纯指针事件处理之上添加了更多功能。例如,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
提供了用于监听以下内容的方法
- 按压、轻触、双击和长按:
detectTapGestures
- 拖动:
detectHorizontalDragGestures
、detectVerticalDragGestures
、detectDragGestures
和detectDragGesturesAfterLongPress
- 变换:
detectTransformGestures
这些是顶层检测器,因此您不能在一个 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
,它不响应指针按下或抬起事件——它只需要知道指针何时进入或退出其边界。
等待特定事件或子手势
有一组方法有助于识别手势的常见部分
- 使用
awaitFirstDown
暂停直到指针按下,或使用waitForUpOrCancellation
等待所有指针抬起。 - 使用
awaitTouchSlopOrCancellation
和awaitDragOrCancellation
创建低级拖动监听器。手势处理程序首先暂停,直到指针达到触控容差,然后暂停,直到第一个拖动事件通过。如果您只对沿单个轴的拖动感兴趣,请改用awaitHorizontalTouchSlopOrCancellation
加awaitHorizontalDragOrCancellation
,或awaitVerticalTouchSlopOrCancellation
加awaitVerticalDragOrCancellation
。 - 使用
awaitLongPressOrCancellation
暂停直到发生长按。 - 使用
drag
方法持续监听拖动事件,或使用horizontalDrag
或verticalDrag
监听单轴上的拖动事件。
应用多点触控事件的计算
当用户使用多个指针执行多点触控手势时,根据原始值理解所需的变换是很复杂的。如果 transformable
修饰符或 detectTransformGestures
方法无法为您的用例提供足够的精细控制,您可以监听原始事件并对其应用计算。这些辅助方法包括 calculateCentroid
、calculateCentroidSize
、calculatePan
、calculateRotation
和 calculateZoom
。
事件调度和命中测试
并非所有指针事件都发送到每个 pointerInput
修饰符。事件调度工作方式如下
- 指针事件被分派到可组合项层次结构。当新指针触发其第一个指针事件时,系统开始对“合格”的可组合项进行命中测试。当可组合项具有指针输入处理能力时,它被认为是合格的。命中测试从 UI 树的顶部流向底部。当指针事件发生在该可组合项的边界内时,该可组合项被“命中”。此过程会生成一个命中测试成功的可组合项链。
- 默认情况下,当树的同一级别上有多个符合条件的可组合项时,只有 Z 轴索引最高的可组合项被“命中”。例如,当您将两个重叠的
Button
可组合项添加到Box
时,只有绘制在顶部的那个会接收任何指针事件。理论上,您可以通过创建自己的PointerInputModifierNode
实现并将sharePointerInputWithSiblings
设置为 true 来覆盖此行为。 - 同一指针的后续事件会分派到同一可组合项链,并根据事件传播逻辑流动。系统不再对此指针执行任何命中测试。这意味着链中的每个可组合项都会接收该指针的所有事件,即使这些事件发生在该可组合项的边界之外。不在链中的可组合项永远不会接收指针事件,即使指针在其边界内。
由鼠标或手写笔悬停触发的悬停事件是此处定义的规则的例外。悬停事件会发送到它们命中的任何可组合项。因此,当用户将指针从一个可组合项的边界悬停到下一个可组合项时,事件会发送到新的可组合项,而不是发送到第一个可组合项。
事件消耗
当有多个可组合项分配了手势处理程序时,这些处理程序不应冲突。例如,看看这个 UI
当用户轻触书签按钮时,按钮的 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 树,其中只有 ListItem
和 Button
响应指针事件
指针事件分三次“传递”流经每个可组合项
- 在初始传递中,事件从 UI 树的顶部流向底部。此流程允许父级在子级消耗事件之前拦截事件。例如,工具提示需要拦截长按,而不是将其传递给其子级。在我们的示例中,
ListItem
在Button
之前接收事件。 - 在主传递中,事件从 UI 树的叶节点流向 UI 树的根。此阶段是您通常消耗手势的地方,也是监听事件时的默认传递。在此传递中处理手势意味着叶节点优先于其父级,这是大多数手势最符合逻辑的行为。在我们的示例中,
Button
在ListItem
之前接收事件。 - 在最终传递中,事件再次从 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 中手势的更多信息
为您推荐
- 注意:禁用 JavaScript 时会显示链接文本
- Compose 中的无障碍功能
- 滚动
- 轻触和按压