在应用中处理手势操作时,理解一些重要的术语和概念至关重要。本页解释了指针、指针事件和手势等术语,并介绍了不同级别的手势抽象。此外,还深入探讨了事件的消费和传播。
定义
为了理解本页上的各种概念,您需要了解一些使用的术语。
- 指针 (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
、combinedClickable
、selectable
、toggleable
和triStateToggleable
修饰符来处理轻触和按下。 - 使用
horizontalScroll
、verticalScroll
和更通用的scrollable
修饰符来处理滚动。 - 使用
draggable
和swipeable
修饰符来处理拖动。 - 使用
transformable
修饰符来处理诸如平移、旋转和缩放之类的多点触控手势。
一般来说,应优先使用现成的修饰符而不是自定义手势处理。这些修饰符在纯指针事件处理的基础上增加了更多功能。例如,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
提供了用于监听以下内容的方法:
- 按下、轻触、双击和长按:
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
会处理该手势并导航到文章。就指针输入而言,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 树,其中只有ListItem
和Button
响应指针事件:
指针事件在三个“传递”期间通过这些可组合项中的每一个三次。
- 在**初始阶段 (Initial pass)**,事件从UI树的顶部向下流动。此流程允许父元素在子元素处理事件之前拦截事件。例如,工具提示 (tooltips) 需要拦截长按事件 (intercept a long-press),而不是将其传递给子元素。在我们的示例中,
ListItem
在Button
之前接收事件。 - 在**主要阶段 (Main pass)**,事件从UI树的叶子节点向上流动到UI树的根节点。此阶段是通常处理手势的阶段,也是监听事件时的默认阶段。在此阶段处理手势意味着叶子节点优先于其父节点,这对于大多数手势来说是最符合逻辑的行为。在我们的示例中,
Button
在ListItem
之前接收事件。 - 在**最终阶段 (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 中手势的更多信息:
为您推荐
- 注意:当 JavaScript 关闭时,将显示链接文本。
- Compose 中的辅助功能
- 滚动
- 点击和按下