在应用中处理手势时,请务必了解几个术语和概念。本页介绍了“指针”“指针事件”和“手势”等术语,并介绍了手势的不同抽象级别。此外,还深入探讨了事件的使用和传播。
定义
如需了解本页面上的各种概念,您需要了解所用的一些术语:
- 指针:可用于与应用互动的实体对象。
对于移动设备,最常见的指针是与触摸屏互动的手指。或者,您也可以使用触控笔代替手指。
对于大屏设备,您可以使用鼠标或触控板与显示屏进行间接互动。输入设备必须能够“指向”某个坐标,才能被视为指针,因此键盘等设备不能被视为指针。在 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 来让其处理点按手势,也可以通过应用 verticalScroll 让 Column 处理垂直滚动。
有许多修饰符可用于处理不同类型的手势:
- 使用
clickable、combinedClickable、selectable、toggleable和triStateToggleable修饰符处理点按和按压操作。 - 使用
horizontalScroll、verticalScroll和更通用的scrollable修饰符处理滚动。 - 使用
draggable和swipeable修饰符处理拖动。 - 使用
transformable修饰符处理多点触控手势,例如平移、旋转和缩放。
一般来说,应优先使用开箱即用的手势修饰符,而不是自定义手势处理。
这些修饰符在纯指针事件处理的基础上添加了更多功能。
例如,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 提供了用于监听以下事件的方法:
- 按、点按、点按两次和长按:
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 修饰符。事件分派的工作原理如下:
- 指针事件会分派到可组合项层次结构。当新指针触发其第一个指针事件时,系统会开始对“符合条件”的可组合项进行点击测试。如果可组合项具有指针输入处理功能,则会被视为符合条件。点击测试从界面树的顶部流向底部。当指针事件发生在可组合项的边界内时,该可组合项即被“命中”。此过程会生成一个可组合项链,其中包含点击测试结果为正的可组合项。
- 默认情况下,如果树的同一级别上有多个符合条件的可组合项,则只有 z-index 最高的可组合项会被“命中”。例如,当您向
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 } } } }
事件传播
如前所述,指针变化会传递给它命中的每个可组合项。
但如果存在多个此类可组合项,事件会按什么顺序传播?如果以上一节中的示例为例,此界面会转换为以下界面树,其中只有 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 中的手势:
为你推荐
- 注意:当 JavaScript 处于关闭状态时,系统会显示链接文字
- Compose 中的无障碍功能
- 滚动
- 点按和按压