在处理应用程序中的手势时,有几个重要的术语和概念需要理解。本页解释了指针、指针事件和手势等术语,并介绍了手势的不同抽象级别。它还深入探讨了事件使用和传播。
定义
要理解本页上的各种概念,您需要了解一些使用的术语
- 指针:您可以用来与应用程序交互的物理对象。对于移动设备,最常见的指针是您的手指与触摸屏的交互。或者,您可以使用触控笔来代替您的手指。对于大屏幕,您可以使用鼠标或触控板间接地与显示器进行交互。输入设备必须能够“指向”坐标才能被视为指针,因此键盘不能被视为指针。在 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
、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 中的无障碍功能
- 滚动
- 点击和按下