万字长文横扫 Compose 手势操作 🤏

2,609 阅读32分钟

万字长文横扫 Compose 手势操作 🤏

Modifier 有一个重要的应用场景,就是用来处理用户的输入事件(如手指触摸、鼠标点击、触控板、手写笔等),从而使元素支持点击、滚动、拖拽、缩放等手势操作。这类处理输入事件的 Modifier 的底层就是 PointerInputModifierNode

// PointerInputModifierNode.kt
interface PointerInputModifierNode : DelegatableNode {
    fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize)
    fun onCancelPointerInput()
    ...
}

基本概念

Pointer 指针

之所以叫 PointerInput 而不是 TouchInput 是因为如今的电子设备具备多种交互输入方式,需要用一个更通用的名字来表示与 app 交互的实体对象——Pointer

Pointer.jpg

注意,只有那些能够“指向”某个坐标的输入设备才能被视为 Pointer,比如手指 👆、鼠标 🖱︎ 等等,不包括键盘 ⌨,因为 Key Event 不能具体“指向”屏幕上的某个坐标。

PointerEvent 指针事件

PointerEvent 用于描述一个或多个 Pointer 在某一时刻与 app 的低级交互。例如:某一时刻手指按下接触到屏幕,会触发一个 PointerEvent,类型是 Press,坐标 (x1, y1);下一时刻手指在屏幕上移动,会触发一个 PointerEvent,类型是 Move,坐标 (x2, y2)。你可以把 PointerEvent 类比传统 View 体系里 onTouchEvent(MotionEvent event) 方法的 event 参数。

Gesture 手势

手势可以理解为是单个操作的一系列 PointerEvent 集合。以点击手势为例,其实就是手指按下再抬起的过程,可以看作是一个类型为 Press 的 PointerEvent,加上一个类型为 Release 的 PointerEvent。

不同级别的手势处理方式

PointerInputModifier 属于较底层的处理输入事件的 API(类似于使用 Android View 里面的 onTouchEvent() 方法),Compose 团队在其基础上封装了一些更加简单易用的上层 API 提供给开发者,用于处理常见的手势操作。总的来说,Compose 里有四种不同级别的手势处理方式,从上至下分别为:

  • Components:内部自带手势处理的开箱即用组件;
  • Gesture Modifier:为组件添加手势处理和额外信息;
  • Gesture Recognizers: 手势侦测器;
  • PointerInputModifier:处理原始的 PointerEvent。

1. Components:内部自带手势处理的开箱即用组件

最上层、最简单的一种,就是使用内部自带手势处理的组件,比如 ButtonSliderLazyColumn... 开发者只需要使用这些组件即可,完全不需要关心手势处理逻辑:

LazyColumn {
  items(count = 100) { i ->
    Text(text = "Item $i")
  }
}

LazyColumnSample.gif

var sliderValue by remember { mutableFloatStateOf(0f) }
Slider(
  value = sliderValue, 
  onValueChange = { sliderValue = it }
)

SliderSample.gif

2. Gesture Modifier:添加手势处理和额外信息

第二种方式也是日常开发中比较常用的,通过 clickable()draggable()verticalScroll() 等 Gesture Modifier,可以让那些内部不包含手势处理的组件也能够支持一些常见的手势操作:

点按/按压操作
clickable()

最常用的一个修饰符,使组件可点击:

var counter by remember { mutableIntStateOf(0) }
Text(
  text = "Click Count: $counter", 
  modifier = Modifier.clickable { counter++ }
)

ClickableSample.gif

combinedClickable()

clickable() 类似,但支持长按/双击/单击:

// Clickable.kt
fun Modifier.combinedClickable(
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onLongClickLabel: String? = null,
  onLongClick: (() -> Unit)? = null,
  onDoubleClick: (() -> Unit)? = null,
  onClick: () -> Unit
)
selectable()

将组件配置为“可选择的”,通常作为互斥组的一部分,同一时间只能选择一个项目。

val options = remember { mutableStateListOf("Option 1", "Option 2", "Option 3") }
var selectedOption by remember { mutableStateOf(options.first()) }

Column(modifier = Modifier.selectableGroup() /* 添加语义信息 */) {
  options.forEach { option ->
    val isSelected = option == selectedOption
    Text(
      text = option,
      modifier = Modifier
        .selectable(
           selected = isSelected, 
           onClick = { selectedOption = option }
         )
        .background(if (isSelected) Color.LightGray else Color.Transparent)
        .padding(16.dp)
    )
  }
}

SelectableSample.gif

toggleable()

将组件配置成“可切换的”(在两种状态中切换,分别用 true 和 false 表示),通常用于让一个元素支持二进制状态切换功能。

var following by remember { mutableStateOf(false) }
Text(
  text = if (following) "已关注" else "未关注",
  modifier = Modifier.toggleable(
    value = following,
    onValueChange = { following = it }
  )
)

ToggleableSample.gif

可能有些人会疑惑,toggleable() 的功能完全可以用 selectable() 替代啊,这不是重复造轮子吗?

请注意看这一小节的标题:“Gesture Modifier:添加手势处理和额外信息”,这里的额外信息指的是:操作时的视觉效果,以及用于无障碍服务的语义信息,对于视觉障碍人士来说,组件的语义信息是非常重要的,因为他们看不见屏幕上的内容,要使用手机上的 app,他们只能靠手指盲摸,TalkBack 服务会根据用户摸到的组件而发声,念出来的内容就是组件包含的语义信息,如果用户摸到的组件是用 selectable() 修饰的,那么他可能会听到“可选择...”,而如果是用 toggleable() 修饰的,那么会听到“可切换...”,所以这两个修饰符有各自不同的适用场景,并不是重复造轮子。

triStateToggleable()

triStateToggleable() 可以看作是一种特殊的 toggleable(),虽然它也是可切换的,但拥有 3 种状态:

  • ToggleableState.On:开;
  • ToggleableState.Off:关;
  • ToggleableState.Indeterminate:未确定。

开就开,关就关,未确定(Indeterminate)是啥?其实很简单,请看下图,一个 Checkbox 可以作为其他多个 Checkbox 的父项,当(取消)选中父项时,属于该父项的所有子项都会被(取消)选中,由于我们可以单独控制某个子项的选中状态,所以父项除了表示“全选”、“全不选”外,还可能表示“部分选中”,这就属于前面提到的 “未确定(Indeterminate)”。

Use a parent checkbox to make it more efficient to select many items.gif

我们手写模拟一下以上场景:

val drinks = remember { mutableStateMapOf("Water" to true, "Tea" to false, "Cola" to false) }
val toggleableState by remember {
  derivedStateOf {
    when {
      drinks.all { it.value } -> ToggleableState.On
      drinks.none { it.value } -> ToggleableState.Off
      else -> ToggleableState.Indeterminate
    }
  }
}

Column {
  Text(
    text = when (toggleableState) {
      ToggleableState.On -> "[✓]" // 全选
      ToggleableState.Off -> "[ ]" // 全不选
      ToggleableState.Indeterminate -> "[-]" // 部分选中
    } + " All",
    modifier = Modifier.triStateToggleable(
      state = toggleableState,
      onClick = {
        val itemsSelected = toggleableState != ToggleableState.On
        drinks.forEach { (drink, _) -> drinks[drink] = itemsSelected }
      }
    )
  )

  drinks.forEach { (drink, selected) ->
    Text(
      text = (if (selected) "[✓]" else "[ ]") + " $drink",
      modifier = Modifier
        .padding(start = 12.dp)
        .toggleable(
          value = selected,
          onValueChange = { drinks[drink] = it }
        )
    )
  }
}

TriStateToggleableSample.gif

拖动操作
draggable()

用于检测拖动手势:

var offsetX by remember { mutableFloatStateOf(0f) }
Box(
  modifier = Modifier
    .offset { IntOffset(offsetX.roundToInt(), 0) }
    .draggable(
      // 检测水平方向的拖动手势
      orientation = Orientation.Horizontal, 
      // 使用 rememberDraggableState 创建并传递一个 DraggableState 对象。
      // 通过 DraggableState 可以获取到拖动手势的偏移量,进一步定义拖动产生的行为。
      state = rememberDraggableState { delta ->
        offsetX += delta
      }
    )
    .background(Green)
    .size(150.dp)
)
  • draggable() 只支持检测单一方向的拖动(水平方向或垂直方向),不支持同时监听两个方向上的拖动偏移,要实现这种效果,需要使用更底层的 PointerInputModifier,后面会讲到。
  • 因为 Modifier 顺序敏感,offset() 必须在 draggable()background() 前面被调用。
    • draggable()offset() 前面:只有初始位置才能响应拖动手势,因为 draggable() 监听区域一直都是初始位置,不是偏移后位置。
    • background()offset()前面: 组件绘制的方块不跟手,因为每次绘制时 background() 都在初始位置绘制,不是偏移后位置。

DraggableSample1.gif

在继续介绍下一个拖动操作符之前,我打算插播一点小内容,介绍一下 draggable() 的各项参数:

fun Modifier.draggable(
  state: DraggableState,
  orientation: Orientation,
  enabled: Boolean = true,
  interactionSource: MutableInteractionSource? = null,
  startDragImmediately: Boolean = false,
  onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
  onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
  reverseDirection: Boolean = false
): Modifier
  • state: DraggableState

    前面我们通过 rememberDraggableState() 创建了一个 DraggableState 对象,同时指定了拖动的回调。为什么不直接用一个函数类型参数设置回调,非要创建一个 DraggableState 对象?其实 Compose 里面很多组件都带有一个 state 参数,因为有时候我们可能需要操作界面上的 UI 对象,比如点击按钮时让 LazyColumn 滑动到最顶部,众所周知 Compose 是声明式 UI 框架,是无法获取 UI 对象实例的。我们虽然不能直接操作 LazyColumn,但我们可以操作它所依赖的状态(state)啊!只要调用 lazyListState.scrollToItem(0) 就可以让列表滑动最顶端。同理,DraggableState 的作用也是如此,比如现在我想让方块不仅可以被用户拖动,而且每次点击时会主动向右拖动 50 个像素,利用 DraggableState 可以轻松实现:

    var offsetX by remember { mutableFloatStateOf(0f) }
    val draggableState = rememberDraggableState { delta ->
      offsetX += delta
    }
    Box(
      modifier = Modifier
        .offset { IntOffset(offsetX.roundToInt(), 0) }
        .draggable(orientation = Orientation.Horizontal, state = draggableState)
        .clickable {
          scope.launch {
            draggableState.drag {
              dragBy(pixels = 50f)
            }
          } 
        }
        .background(Green)
        .size(150.dp)
    )
    

    DraggableState.gif

  • orientation: Orientation:拖动的方向,Orientation.VerticalOrientation.Horizontal

  • enabled: Boolean:是否启用拖动手势监听。

  • interactionSource: MutableInteractionSource

    "interaction" 交互,"source" 来源,交互来源是什么东西?既然看不出来它是什么,不如直接创建一个 MutableInteractionSource 实例,看看它有什么属性或方法:

    image-20240802134035474.png

    原来可以通过 interactionSource 监听组件的拖动、按压、悬停、焦点等状态,💡 懂了 。

    var offsetX by remember { mutableFloatStateOf(0f) }
    val draggableState = rememberDraggableState { delta ->
      offsetX += delta
    }
    val interactionSource = remember { MutableInteractionSource() }
    val isDragging by interactionSource.collectIsDraggedAsState() // 📌
    
    Box(
      modifier = Modifier
        .offset { IntOffset(offsetX.roundToInt(), 0) }
        .draggable(
           orientation = Orientation.Horizontal, 
           state = draggableState,
           interactionSource = interactionSource
        )
        .background(Green)
        .size(150.dp)
    ) {
        if(isDragging) Text(text = "Dragging")
    }
    

    interactionSource.gif

  • startDragImmediately: Boolean:是否立即开始拖动。

  • onDragStarted:拖动开始的回调。

  • onDragStopped:拖动结束的回调。

  • reverseDirection: Boolean:是否反转方向。

好,插播结束,我们继续下一个拖动手势修饰符。

anchoredDraggable()

anchoredDraggable() 修饰符主要用于打造带有锚定状态的可拖动组件,例如 Bottom Sheet、Drawer,也可用于实现滑动删除(swipe-to-dismiss)等功能。说人话就是:允许开发者设置锚点,实现在不同状态间滑动并带有吸附动画的效果。

不过,我不打算在这讲解这个 anchoredDraggable() 修饰符,因为它涉及到较多的新概念,三言两语很难讲清楚。它的前身是 swipeable() 修饰符,定义在 Compose-Material 库,Compose-Foundation 1.6.0-alpha01 后被挪到了 Foundation 库,改名为 anchoredDraggable(),目前仍是一个实验性 API。如果你刚好有这方面的需求,可以自行搜索学习,这里推荐一篇相关的文章:How to Implement Swipe-to-Action using AnchoredDraggable in Jetpack Compose

gestures-swipe.gif

滚动操作
verticalScroll() / horizontalScroll()

这两个滚动修饰符使元素在内容尺寸 > 最大尺寸约束时可滚动。

// ScrollState 可以让我们更改滚动位置或获取当前滚动状态
val scrollState: ScrollState = rememberScrollState()
Column(
  modifier = Modifier
    .size(300.dp)
    .verticalScroll(scrollState)
) {
  repeat(16) {
    Box(Modifier.background(randomColor()).fillMaxWidth().height(50.dp))
  }
}

VerticalScrollSample.gif

其实就相当于传统 View 系统里面套一层 ScrollView 的做法。

以上例子只是为了展示滚动修饰符的使用方法,对于长列表场景,正确的选择是 LazyColumn() / LazyRow(),因为它们具备更好的性能。滚动修饰符的使用场景一般是:当普通组件的宽度或高度超出屏幕边界时,期待能滑动查看更多内容。

scrollable()

虽然 verticalScroll() / horizontalScroll()scrollable() 的名字很像,但它们并不是相同的东西,scrollable() 修饰符仅负责检测滚动手势,并不会帮我们自动偏移元素内容,滚动行为由开发者定义,用法类似 draggable() 修饰符:

var offsetX by remember { mutableFloatStateOf(0f) }
Column {
  Text(text = "OffsetX: $offsetX")
  Box(
    Modifier
    .size(200.dp)
    .background(Pink)
    .scrollable(
      // 检测水平方向的滚动手势
      orientation = Orientation.Horizontal,
      // 使用 rememberScrollableState 创建并传递一个 ScrollableState 对象。
      // 通过 ScrollableState 可以获取到滚动手势的偏移量,进一步定义滚动行为。
      state = rememberScrollableState { delta ->
        offsetX += delta
        delta // 为了支持嵌套滚动,必须返回消费的滚动距离量
      }
    )
  )
}

ScrollableSample.gif

乍一看 scrollable()draggable() 好像别无二样,它俩的区别在于:draggable() 仅响应拖动手势,而 scrollable() 更高级,支持惯性滑动以及嵌套滚动,如果只需实现简单的拖动,使用 draggable() 就足矣。

// Scrollable.kt
fun Modifier.scrollable(
  state: ScrollableState,
  orientation: Orientation,
  enabled: Boolean = true,
  reverseDirection: Boolean = false,
  flingBehavior: FlingBehavior? = null, // 📌
  interactionSource: MutableInteractionSource? = null
): Modifier

可以看到,相较于前面 draggable() 的参数,scrollable() 多了一个 flingBehavior 用于设置惯性滚动行为。🤔 上面的示例里没有传递这个参数啊,默认值是 null,为什么也支持惯性滚动效果呢?通过翻源码可以发现内部会默认指定一个惯性滚动行为:

// Scrollable.kt
private class ScrollableNode(
  ...,
  private var flingBehavior: FlingBehavior?,
) : ... {
  ...
  val scrollingLogic = ScrollingLogic(
    ...,
    flingBehavior = flingBehavior ?: defaultFlingBehavior, // 📌
  )
}

另外,scrollable() 修饰符还有第二个版本,多了两个参数:

// Scrollable.kt
fun Modifier.scrollable(
  ...,
  overscrollEffect: OverscrollEffect?, // 📌
  bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec() // 📌
)

OverscrollEffect 用于设置滚动触边效果;而 BringIntoViewSpec 有点复杂,让我举个例子解释一下,假设用滚动组件实现一个“评论区”容器,现在我要跳转到最底部的那条评论(让最后一条评论滚动到视口内),就可以用 BringIntoViewSpec 来指定滚动行为,包括滚动过程的动画曲线、滚动距离(让 item 停靠在容器上方、下方或中心)。

多点触控
transformable()

用于检测变换手势(拖动、缩放、旋转),使用方法与 draggable() 以及 scrollable() 类似,不再赘述。

Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
  var offset by remember { mutableStateOf(Offset.Zero) }
  var angle by remember { mutableFloatStateOf(0f) }
  var scale by remember { mutableFloatStateOf(1f) }
  val transformableState: TransformableState = 
    rememberTransformableState { zoomChange, panChange, rotationChange ->
      scale *= zoomChange
      offset += panChange
      angle += rotationChange
    }

  Box(
    modifier = Modifier
    // 注意 rotate 与 offset 的先后调用顺序
    .rotate(angle)
    .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
    .scale(scale)
    .transformable(state = transformableState)
    .background(Yellow)
    .size(200.dp)
  )
}

TransformableSample.gif

fun Modifier.transformable(
  state: TransformableState,
  lockRotationOnZoomPan: Boolean = false,
  enabled: Boolean = true
) : Modifier

参数 lockRotationOnZoomPan 的作用是:如果设置为 true,在“旋转”之前如果发生了“移动”或“缩放”,就不会再检测“旋转”,反之亦然,在“移动”或“缩放”之前如果发生了“旋转”,就不会再检测“移动”和“缩放”。

💨 呼~~~ 终于把好几个 Gesture Modifier 讲完了,🥛 喝口水我们继续,长征尚未胜利,同志们仍需努力 🚩。

3.Gesture Recognizers: 手势侦测器

第三种方式,Gesture Recognizers 手势侦测器,类似于 View 里面的 GestureDetector,我们不直接与最底层的 PointerEvent 交互,而是使用一些手势侦测 API 来代理我们处理这些 PointerEvent。

回想一下,在传统自定义 View 里,双击手势是怎么实现的,虽然没有 setOnDoubleClick() 这种上层 API,但也不至于要在 onTouchEvent() 方法里手写算法来检测双击手势,通常的做法是使用 GestureDetector,把 onTouchEvent() 回调收到的 MotionEvent 转发给 GestureDetector 去处理:

class GestureDetectorSample(context) : View(context) {
  private inner class MyGestureListener : GestureDetector.SimpleOnGestureListener() {
    // 只有在 Down 事件时返回 true 才能收到后续事件
    override fun onDown(e: MotionEvent): Boolean = true  
    override fun onDoubleTap(e: MotionEvent): Boolean { 
      /* Do something when double click. */
      return true
    }
  }

  private val gestureDetector = GestureDetector(context, MyGestureListener())
    
  override fun onTouchEvent(event: MotionEvent): Boolean =
    gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
    // 将 event 转发给 GestureDetector 处理
}

关联阅读:GestureDetector 类的手势操作方法含义

在 Compose 里,与上面等价的代码要怎么写呢?其实非常简单:

Modifier.pointerInput(Unit) {
  detectTapGestures(onDoubleTap = { /* Do something when double click. */ })
}

首先我们在这里接触到一个新的修饰符:pointerInput(),你可以暂且将其理解为 onTouchEvent() 的入口。可以看到它有一个函数参数 block(是挂起函数 ⏸️,接收者类型为 PointerInputScope),通过 PointerInputScope 我们可以获取到最底层的事件 PointerEvent(后面会详细说)。

// SuspendingPointerInputFilter.kt
fun Modifier.pointerInput(
  key1: Any?, 
  block: suspend PointerInputScope.() -> Unit
): Modifier

然后我们在 lambda 里面调用了 PointerInputScope 的拓展函数 detectTapGestures() 函数就行了。是的,就这么简单!既不需要创建什么 GestureDetector 对象实例,也不需要获取事件再转发给第三方处理,detectTapGestures() 函数已经帮你处理好一切。

// TapGestureDetector.kt
suspend fun PointerInputScope.detectTapGestures(
  onDoubleTap: ((Offset) -> Unit)? = null,
  onLongPress: ((Offset) -> Unit)? = null,
  onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
  onTap: ((Offset) -> Unit)? = null
)

类似的 detect... 函数一共有 6 个,使用方法都类似,聪明的你肯定能举一返五:

  • detectTapGestures()
  • detectDragGestures()
  • detectTransformGestures()
  • detectDragGesturesAfterLongPress()
  • detectHorizontalDragGestures()
  • detectVerticalDragGestures()

对了,detectDragGestures() 方法是可以同时检测 X 和 Y 方向的拖动的:

 var offset by remember { mutableStateOf(Offset.Zero) }
...
Modifier
  .offset { offset.round() }
  .pointerInput(Unit) {
    detectDragGestures { change: PointerInputChange, dragAmount: Offset ->
      offset += dragAmount
    }
  }

DetectDragGesturesSample.gif

可能会有朋友有疑问,这些 detect 函数不是与前面的 Gesture Modifier 高度重复吗?比如 detectTapGestures()combinedClickable(),区别在哪?

其实前面已经稍微提到过了,combinedClickable() 属于上层 API,它们除了会处理手势,还会为组件添加相应的语义信息、操作时的视觉效果(如点击涟漪),而 detectTapGestures() 属于下层 API,它们仅负责手势处理。在满足需求的前提下,应该尽量使用上层的 Gesture Modifier。

另外有两件事情需要注意:

  • pointerInput() 的第一个参数 key,用过 Compose 相关副作用 API 的朋友应该对这个参数感到熟悉,不过我还是在这里简单介绍一下:它用于取消参数 block 的协程作用域,并重新调用参数 block。什么意思呢?举个例子就很容易明白了,假设现在要把双击的位置打印出来,但在打印前先加上一个额外的偏移。如果双击的地方是 (100, 100),打印结果就是 (101, 101)

    var extraOffset by remember { mutableStateOf(Offset(x = 1f, y = 1f)) }
    ...
    Modifier.pointerInput(key1 = Unit) {
      detectTapGestures(
        onDoubleTap = { offset -> println(offset + extraOffset) }
      )
    }
    

    在程序运行过程中,如果 extraOffset 被更新为 Offset(2f, 2f),再次点击 (100, 100) 位置,预期的打印结果是 (102, 102),但实际仍然是 (101, 101),除非将 lambda 所在协程作用域取消,并重新运行 lambda(我们把这个过程称为“重启”)。要做到在 extraOffset 发生改变时重启,只需将其作为 key 参数传递给 pointerInput() 修饰符。不需要重启的话,填 Unit 或 null 就行了。

    - Modifier.pointerInput(key1 = Unit)
    + Modifier.pointerInput(key1 = extraOffset)
    
  • 第二,detectTapGestures() 也是一个挂起函数 ⏸️(内部会不断循环检测点击手势),这意味着在它后面的代码是不会被执行的:

    Modifier.pointerInput(Unit) {
      detectTapGestures(...) // <- ⏸️ 挂起函数
      detectDragGestures(...) // ❌ 这行永远不会被执行
    }
    

    要同时侦测多种手势,正确的写法是:

    Modifier
      .pointerInput(Unit) {
        detectTapGestures(...)
      }
      .pointerInput(Unit) {
        detectDragGestures(...) // ✅
      }
    

4. PointerInputModifier:处理原始的 PointerEvent

终于要讲最底层的 PointerEvent 了,先来个最简单的需求吧:把接收到的指针事件(类型和坐标)打印出来。

在传统 View 系统里面写起来很简单,回调函数 onTouchEvent 有一个参数 event,类型为 MotionEvent,直接打印它就 OK 了:

override fun onTouchEvent(event: MotionEvent): Boolean {
  println("${event.actionMasked}, (${event.x}, ${event.y})")
  return true
}

Compose 的 pointerInput() 修饰符要从哪里找 PointerEvent 呢?

var log by remember { mutableStateOf("") }
...
Modifier.pointerInput(Unit) {
  awaitPointerEventScope { // ⏸️ install a pointer input handler
    val event: PointerEvent = awaitPointerEvent() // ⏸️ receive and consume pointer input events
    log = "${event.type}, ${event.changes[0].position}"
  }
}

我们先在 PointerInputScope 里面调用了 awaitPointerEventScope(),注意这也是一个挂起函数 ⏸️,它的函数参数 block 提供了 AwaitPointerEventScope 上下文环境。

// SuspendingPointerInputFilter.kt
interface PointerInputScope : ... {
  suspend fun <R> awaitPointerEventScope(
      block: suspend AwaitPointerEventScope.() -> R
  ): R
}

只有在 AwaitPointerEventScope 的作用域内我们才能调用各种函数来获取指针事件 PointerEvent,比如 awaitPointerEvent(),它又双叒是一个挂起函数 ⏸️,会将当前协程挂起,直到有任一类型的手势事件发生才会返回结果 PointerEvent,并恢复协程。

// SuspendingPointerInputFilter.kt
interface AwaitPointerEventScope : ... {
  suspend fun awaitPointerEvent(pass: PointerEventPass = PointerEventPass.Main): PointerEvent
}

先看看运行效果吧:

LogPointerEventsWithBug.gif

❓为什么只有第一个 Press 事件,后续的移动和抬起事件哪去了?

为了解释这个问题,我们先回顾一下上面的两段代码:

// in Android View:
override fun onTouchEvent(event: MotionEvent): Boolean {
  println("${event.actionMasked}, (${event.x}, ${event.y})")
  return true
}

// in Jetpack Compose:
Modifier.pointerInput(Unit) {
  awaitPointerEventScope { // ⏸️ 挂起函数
    val event: PointerEvent = awaitPointerEvent() // ⏸️ 挂起函数
    log = "${event.type}, ${event.changes[0].position}"
  }
}

毋庸置疑,onTouchEvent() 是一个回调函数,每当产生了新的 MotionEvent,该函数就会被重新调用。再看 Compose 的写法,有回调吗?没有❗😱 PointerEvent 是我们通过 awaitPointerEvent() 主动获取的,根本不存在什么回调。只是说 awaitPointerEvent() 作为一个挂起函数,它会挂起当前协程,一直等,等到有新的 PointerEvent 产生,便将结果返回,恢复协程。在我们的例子里,它等到了它的意中人(类型为 Press 的 PointerEvent),然后代码继续往下执行,更新 log 变量,再然后?没有然后了啊,🔚 协程里的代码运行完就结束了。

所以,想要打印所有的 PointerEvent,我们就得源源不断地主动获取 PointerEvent:

Modifier.pointerInput(Unit) {
  awaitPointerEventScope {
+    while(true) {
       val event: PointerEvent = awaitPointerEvent()
       log = "${event.type}, ${event.changes[0].position}"
+    }
  }
}

LogPointerEvents.gif

也许你想问,为什么在使用 Gesture Recognizers 时没有无限循环也能不断地检测手势?

Modifier.pointerInput(Unit) {
  while(true) { // <-- No need here
    detectTapGestures(...)
  }
}

其实 detectTapGestures() 函数内部已经用了无限循环,这个函数设计出来就是不断检测 tap 手势的,而不是检测单个 tap 手势,这也是为什么它的名字叫做:detectTapGestures

问题解决了,问题又来了。🤔 为什么 Compose 要抛弃传统的回调监听方式,使用协程的挂起恢复机制来处理输入事件?

💁 这是因为,当我们真的要写自定义手势时,整体流程是比较复杂的, 肯定不会像上面那样获取了事件然后打印这么简单。使用回调的方式检测手势,在回调里不可避免地要将一些中间状态保存到外部变量,并在取消手势时重置这些状态;而如果用协程的挂起和恢复,我们的代码是同步风格的,自然不需要这些外部变量,这是协程挂起恢复机制处理输入事件的优势之一。

事件分发

不知道你是否有注意到,awaitPointerEvent() 有一个可选参数 PointerEventPass,它决定了组件应该在哪个事件分发阶段获取(返回)PointerEvent 事件。

suspend fun awaitPointerEvent(pass: PointerEventPass = PointerEventPass.Main): PointerEvent

在 Compose 里面,一次 PointerEvent 的完整分发流程分为三个阶段:

// PointerEvent.kt
enum class PointerEventPass {
  Initial, Main, Final
}
  1. 在所有使用 Initial 参数的组件中,从父组件向子组件方向分发:
  2. 在所有使用 Main 参数的组件中,从子组件向父组件方向分发 :
  3. 在所有使用 Final 参数的组件中,从父组件向子组件方向分发:

Inital 阶段使父组件有机会预先消费事件;Main 阶段使子组件能优先于父组件完成手势事件的处理;Final 阶段一般用于让组件了解经历过前两个阶段后的手势事件消费情况,从而确定自身行为。

我们来写一个示例验证一下事件分发过程,嵌套的 6 个 Box 组件,每个组件在不同的事件分发阶段获取同一个 PointerEvent 事件,然后更新 log 变量拼接上自己的颜色。

var log by remember { mutableStateOf("") }
Box(
  modifier = Modifier
    .pointerInput(Unit) {
      awaitPointerEventScope {
          awaitPointerEvent(pass = PointerEventPass.<分发阶段>)
          log += "<颜色>"
      }
    }
    .background(<颜色>)
    .size(xxx.dp)
) {
  Text(text = "<分发阶段>", modifier = Modifier.align(Alignment.BottomEnd))
  /* 嵌套 Box */
  Text(text = log)
}

事件分发.gif

sequenceDiagram
title PointerEvent 事件分发
父组件 ->> 子组件: 1.Initial:白->蓝
子组件 ->> 父组件: 2.Main:绿<-紫
父组件 ->> 子组件: 3.Final:粉->黄
事件消费

了解了 Compose 中 PointerEvent 事件分发之后,接下来就要学习如何消费事件了。

回顾一下,在 Android View 里面,事件的分发、拦截、消费,主要与 dispatchTouchEvent()onInterceptTouchEvent()onTouchEvent() 这 3 个函数及其返回值相关。对很多人来说(包括我),这是一块让人头疼的知识。

关联阅读:

MotionEvent 的完整分发过程只有两个阶段,从父组件向子组件分发,再从子组件向父组件分发,相比 Compose 少了一个阶段:

sequenceDiagram
Title: MotionEvent 事件分发(无拦截消费)
父组件A ->> 子组件B: MotionEvent
子组件B ->> 子组件的子组件C: MotionEvent
子组件的子组件C ->> 子组件B: MotionEvent
子组件B ->> 父组件A: MotionEvent

事件从父组件向子组件方向分发时,父组件可以进行事件拦截(onInterceptTouchEvent 返回 true),拦截后事件不会继续往子组件方向分发:

sequenceDiagram
Title: MotionEvent 事件分发(B 将事件拦截,但不消费)
父组件A ->> 子组件B: MotionEvent
Note over 子组件B: 拦截事件,但不消费
子组件B -->> 子组件的子组件C: 🚫
子组件的子组件C -->> 子组件B: 🚫
子组件B ->> 父组件A: MotionEvent

事件从子组件向父组件方向分发时,组件可以进行事件消费(onTouchEvent 返回 true),消费后事件不会继续往父组件方向分发:

sequenceDiagram
Title: MotionEvent 事件分发(B 将事件消费)
父组件A ->> 子组件B: MotionEvent
子组件B ->> 子组件的子组件C: MotionEvent
子组件的子组件C ->> 子组件B: MotionEvent
Note over 子组件B: 消费事件
子组件B -->> 父组件A: 🚫

综上可以看出,在 Android View 里面,事件被拦截或消费后,完整的分发流程就被破坏了。

🔔📣📢 与 Android View 不同,在 Compose 里面,没有所谓的事件拦截,只有事件消费,而且消费也不会影响事件后续的分发流程,Compose 里事件消费只是将事件打上一个标记。

比如下图,组件 B 在 Initial 阶段消费了事件(将事件打上标记),不会影响事件后续的分发。

sequenceDiagram
Title: PointerEvent 事件分发(B 将事件消费)
父组件A ->> 子组件B: PointerEvent
Note over 子组件B: 消费事件
子组件B ->> 子组件的子组件C: PointerEvent'
子组件的子组件C ->> 子组件B: PointerEvent'
子组件B ->> 父组件A: PointerEvent'
父组件A ->> 子组件B: PointerEvent'
子组件B ->> 子组件的子组件C: PointerEvent'

OK,有了理论的支撑,接下来我们就从代码层面去看看,怎么消费事件

// PointerEvent.android.kt
actual class PointerEvent internal actual constructor(
  actual val changes: List<PointerInputChange>, // 📌
  internal val internalPointerEvent: InternalPointerEvent?
) {
  // Android View 里面的 MotionEvent,但是 internal,无法访问
  internal val motionEvent: MotionEvent?
      get() = internalPointerEvent?.motionEvent
  ...
}

注意看 PointerEvent 的 changes 属性,类型是 List<PointerInputChange>,包含了一次手势交互中所有指针(手指)的交互信息。也就是说,单根指针的完整交互信息是存在 PointerInputChange 里面的:

// PointerEvent.kt
class PointerInputChange(
  val id: PointerId, // 指针(手指)的唯一 ID
  val uptimeMillis: Long, // 该指针最新事件的时间戳
  val position: Offset, // 当前最新位置(相对于组件左上角)
  val pressed: Boolean, // 该指针最新事件是否应视为“已按下”,比如手指正摸着屏幕、或者鼠标按钮正被按着,pressed 就为 true
  val pressure: Float, // 指针事件的压力
  val previousUptimeMillis: Long, // 该指针上一事件的时间戳
  val previousPosition: Offset, // 上次事件的位置
  val previousPressed: Boolean, // 该指针上一事件是否应视为“按下”
  isInitiallyConsumed: Boolean, // 
  val type: PointerType = PointerType.Touch, // 产生指针的设备类型(手指、鼠标...)
  val scrollDelta: Offset = Offset.Zero // 滚轮在 X 和 Y 方向上的滚动量
) {
  // 👇👇👇
  @Deprecated("use isConsumed and consume() pair of methods instead")
  var consumed: ConsumedData =
    ConsumedData(downChange = isInitiallyConsumed, positionChange = isInitiallyConsumed)
    private set
  
  // ❓ 返回事件是否已经被消费
  val isConsumed: Boolean
    get() = consumed.downChange || consumed.positionChange
  
  // 🔖 将事件标识为“已消费”
  fun consume() {
    consumed.downChange = true
    consumed.positionChange = true
  }
  ...
}

很明显,成员变量 consumed 记录了 PointerInputChange 是否被消费,通过注解得知,官方建议开发者不要直接操作 consumed,而是使用 isConsumedconsume() 来(判断)消费 PointerInputChange。

我们已经知道了可以通过 isConsumed 属性和 consume() 函数来(判断)消费 PointerInputChange,但还是感觉摸不着头脑... Android View 里面“消费事件”意味着事件不再继续分发,可是 Compose 里面,把 PointerInputChange 消费了,它还是会继续分发啊(只是 isConsumed 值为 true),所以我应该在每次使用 PointerInputChange 之前先调用 isConsumed 判断它是否已经被消费吗???

🆙 废话不多说,还是直接上代码吧!

consume.png

这次我们用 3 个嵌套 Box,右下角代表着组件在哪个阶段获取事件,左下角代表着手指相对于上一次事件所移动的距离。根据前面的知识很容易知道,事件的流向是:🟦(Initial) -> 🟪(Main) -> 🟨(Final),我们在 Main 阶段调用 consume() 方法将事件消费掉。

紫色 Box:
Modifier.pointerInput(Unit) {
  awaitPointerEventScope {
    while (true) {
      val event = awaitPointerEvent(pass = PointerEventPass.Main)
      positionChangeInPurpleBox = event.changes[0].position - event.changes[0].previousPosition
      event.changes[0].consume() // 📌 在 Main 阶段将事件标记为已消费
    }
  }
}

蓝色 Box:
Modifier.pointerInput(Unit) {
  awaitPointerEventScope {
    while (true) {
      val event = awaitPointerEvent(pass = PointerEventPass.Initial)
      positionChangeInBlueBox = event.changes[0].position - event.changes[0].previousPosition
    }
  }
}

黄色 Box:
Modifier.pointerInput(Unit) {
  awaitPointerEventScope {
    while (true) {
      val event = awaitPointerEvent(pass = PointerEventPass.Final)
      positionChangeInYellowBox = event.changes[0].position - event.changes[0].previousPosition
    }
  }
}

运行,指针在屏幕上右滑动,可以看到 3 个组件都收到事件,即使在 Main 阶段已经将事件标记为“已消费”,Final 阶段还是能收到事件,这也印证了:标记消费不会影响事件传递。

consume1.gif

我们把上面代码里的 position - previousPosition 改成 positionChange() 试试看:

- xxx = event.changes[0].position - event.changes[0].previousPosition
+ xxx = event.changes[0].positionChange()

consume2.gif

哎,成了,Final 阶段获取事件的 🟨 似乎感受不到手指滑动了,点开 positionChange() 源码看看:

fun PointerInputChange.positionChange() : Offset 
  = this.positionChangeInternal(ignoreConsumed = false)

private fun PointerInputChange.positionChangeInternal(ignoreConsumed: Boolean = false): Offset {
  val previousPosition = previousPosition
  val currentPosition = position
  val offset = currentPosition - previousPosition
  return if (!ignoreConsumed && isConsumed) Offset.Zero else offset
}

果然不出所料,只是函数内部会判断 isConsumed 标记,如果已消费,就返回 Offset.Zero,否则返回 position - previousPosition。就这?😂 是的,就这。

如果你不介意事件已经被消费,就是要获取移动距离,可以使用 positionChangeIgnoreConsumed(),效果和手动相减 positionpreviousPosition 没区别。

fun PointerInputChange.positionChangeIgnoreConsumed(): Offset = 
  this.positionChangeInternal(ignoreConsumed = true)

到现在,你应该已经能意识到,Compose 里的事件消费和 Android View 那套完全就不是一个东西。

  • Android View 的事件消费,是宣布主权,这个事件我要处理,其他组件不许处理!
  • Compose 的事件消费,是温馨提醒,这一刻手指移动了,组件我已经响应移动这个动作了(消费移动事件了),其他组件要不要继续用二手的移动事件,我也管不着,反之我已经告诉它们了。

个人感觉 Compose 里的事件分发和消费,相比 Android View 真的要简单很多,主要是思维逻辑上的转变,剩下的 API 调用真的不是难事。

// 判断指针这一刻是否为按下动作
fun PointerInputChange.changedToDown(): Boolean = !isConsumed && !previousPressed && pressed
// 判断指针这一刻是否为按下动作,忽略消费标记。
fun PointerInputChange.changedToDownIgnoreConsumed(): Boolean = !previousPressed && pressed

// 判断指针这一刻是否为抬起动作
fun PointerInputChange.changedToUp(): Boolean = !isConsumed && previousPressed && !pressed
// 判断指针这一刻是否为抬起动作,忽略消费标记。
fun PointerInputChange.changedToUpIgnoreConsumed(): Boolean = previousPressed && !pressed

// 判断指针相对上一刻位置是否发生变化
fun PointerInputChange.positionChanged(): Boolean =
  this.positionChangeInternal(ignoreConsumed = false) != Offset.Companion.Zero
// 判断指针相对上一刻位置是否发生变化,忽略消费标记
fun PointerInputChange.positionChanged(): Boolean =
  this.positionChangeInternal(ignoreConsumed = true) != Offset.Companion.Zero

// 判断指针当前位置是否处于给定区域内
fun PointerInputChange.isOutOfBounds(size: IntSize, extendedTouchPadding: Size): Boolean {
  ...
}
手写 Click 手势检测

这还是国内吗.gif

还记得前面的知识吗?我们从上往下学习了 4 种不同级别的手势处理方式,然后从 awaitPointerEvent() 讲到了事件分发,再到事件消费,不知道一路上你是否有些许收获?不管怎样,我们还是回归到实践当中,手写几个手势侦测器,巩固一下知识吧。

开胃菜,先写一个点击手势。所谓的手势,就是单个操作的一系列 PointerEvent 集合,点击手势就是 1 个 Press 事件 + 1 个 Release 事件。

@Composable
fun Modifier.tap(onTap: () -> Unit) = this.pointerInput(onTap) {
  awaitPointerEventScope {
    val down = awaitPointerEvent()
    while (true) {
      val pointerEvent = awaitPointerEvent()
      if(pointerEvent.type == PointerEventType.Release) {
        onTap()
      }
    }
  }
}

我们很容易写出以上代码,先用 awaitPointerEvent() 获取 1 个按下事件(组件接收的第一个事件肯定是 Press,所以这里无需判断类型,甚至这行代码都不是必需的),然后开启循环,不断取事件,判断是否为“抬起”,是则触发点击。

代码能跑,但不严谨。假设现在按下了两根手指,再先后抬起,会触发两次点击。对于多根手指按下的点击手势,合理的做法是在最后一根手指抬起时触发操作:

if(pointerEvent.type == PointerEventType.Release) {
   // 确保抬起的是最后一根手指
+  if(pointerEvent.changes.size == 1) {
    onTap()
+  }
}

另外,在手指按下期间(无论是单指还是多指),只要有任一手指移动超出边界,就应该取消手势:

pointerInput(Unit) {
  awaitPointerEventScope {
    val down = awaitPointerEvent()
    while (true) {
      val pointerEvent = awaitPointerEvent()
      when (pointerEvent.type) {        
         PointerEventType.Release -> {
           if (pointerEvent.changes.size == 1) {
             onTap()
           }
         }

         // 如果有手指移动出界,break 跳出循环
+        PointerEventType.Move -> {
+          val out = pointerEvent.changes.fastAny { pointerInputChange ->
+            pointerInputChange.isOutOfBounds(size, extendedTouchPadding)
+          }
+          if (out) break
+        }
      }
    }
  }
}

加上边界判断后,我们发现了一个新问题,如果手指移出边界,手势取消,break 跳出循环,那么协程里的代码就全部执行完了,协程都结束了那后续的第二、第三...个点击手势肯定检测不了了,所以我们还得在最外层再套一个无限循环,以便在处理完一轮手势后,再次从头检测新的一轮手势:

pointerInput(Unit) {
  awaitPointerEventScope {
     // 不断检测:“按下” -> “抬起”
+    while(true) {
      val down = awaitPointerEvent()
      while (true) {
        val pointerEvent = awaitPointerEvent()
        when (pointerEvent.type) {
          PointerEventType.Move -> {
            val out = pointerEvent.changes.fastAny { pointerInputChange ->
					   pointerInputChange.isOutOfBounds(size, extendedTouchPadding)
            }
            if (out) break
          }

          PointerEventType.Release -> {
            if (pointerEvent.changes.size == 1) {
              onTap()
+              break
            }
          }
        }
      }
+    }
  }
}

终于要完成了...吗?很遗憾,还没有。我们还需要确保:在进行新一轮手势检测前,所有的手指已经全部抬起。

我们目前的代码逻辑是,跳出循环(开启新一轮手势检测),要么是因为最后一根手指抬起,要么是因为有手指移出了边界,前一种情况是 OK 的,但是后者就有问题了,因为开始新一轮手势检测时,上一轮的手指还没有全部抬起,这会对后续的检测产生影响。我们没办法保证第一行代码 val down = awaitPointerEvent() 获取到的一定是按下事件,获取到的可能是没有抬起的手指的移动事件。具体到我们的例子里面,会导致手指按下,移到边界外再抬起,也会触发点击手势。

那么,怎么确保在一轮手势结束后,所有手指全部都抬起,再进行下一轮手势呢?很简单,使用 awaitEachGesture()

// ForEachGesture.kt
suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
  val currentContext = currentCoroutineContext()
  awaitPointerEventScope {
    while (currentContext.isActive) {
      try {
        block()
        awaitAllPointersUp() // 📌
      } catch (e: CancellationException) {
        if (currentContext.isActive) awaitAllPointersUp() else throw e
      }
    }
  }
}

可以看到,awaitEachGesture() 其实就是对 awaitPointerEventScope() 和无限循环的一种封装,每次循环后都会保证所有手指抬起。这个函数可以说是自定义手势的必备,随便点开一个 detect 系列函数,内部肯定调用了它。

所以我们可以把上面的代码改成:

pointerInput(Unit) {
+  awaitEachGesture {
-  awaitPointerEventScope {
-    while(true) {
      val down = awaitPointerEvent()
      while (true) {
        val pointerEvent = awaitPointerEvent()
        when (pointerEvent.type) {
          PointerEventType.Move -> {
            val out = pointerEvent.changes.fastAny { pointerInputChange ->
              pointerInputChange.isOutOfBounds(size, extendedTouchPadding)
            }
            if (out) break
          }

          PointerEventType.Release -> {
            if (pointerEvent.changes.size == 1) {
              onTap()
              break
            }
          }
        }
      }
-    }
  }
}

目前我们写的这个点击侦测器,不能说完美(因为没考虑事件消费),但已经很不错了 😉

双指下拉

第二个例子,我们来实现“双指下拉”。首先分析一下,下拉触发动作,那么肯定有个距离的阈值,超过这个阈值就触发动作,下拉的距离从哪来?是当前双指的中心点,减去双指刚按下时的中心点。

我们可以开一个循环不断获取事件,判断是否刚好有两根手指正按着屏幕,是就累加当前中心点相对上一刻中心点的位移,否就将累加值置零(因为可能有手指抬起了,或者新手指按下了),随后判断累加值是否超过阈值以触发动作:

@Composable
fun Modifier.twoFingerPullDown(threshold: Float = 200f, action: () -> Unit) =
  this.pointerInput(action) {
    awaitEachGesture {
      var offsetY = 0f
      while (true) {
        val pointerEvent = awaitPointerEvent()
        if (
          pointerEvent.changes.size == 2
          && pointerEvent.changes.fastAll { it.pressed }
        ) {
          // 加上偏移
          // PointerEvent.calculatePan() 作用是计算此刻所有手指的中心点位置相对上一刻的变化
          offsetY = offsetY + pointerEvent.calculatePan().y
        } else { // 不满足条件
          offsetY = 0f
          continue
        }

        if (offsetY > threshold) {
          action()
          break
        }
      }
    }
  }

怎么样,是不是很简单?

我们再把情况考虑全面一些:

  • 下拉过程中,如果有手指移动到边界外,则重置累加值;
  • 确保累加值不小于 0,否则用户可能需要下拉更长的距离。什么意思呢?举个例子,当你按下双指到屏幕横向中线,先上拉 200px,此时累加值 offsetY 为 -200,然后双指再下拉,这时需要下拉 300px 才能触发动作(因为我们设置的阈值是 200px),这是不符合预期的。
...
if (
  pointerEvent.changes.size == 2
  && pointerEvent.changes.fastAll {
       it.pressed 
+        && !it.isOutOfBounds(size, extendedTouchPadding)
     }
) {
-  offsetY = offsetY + pointerEvent.calculatePan().y
+ offsetY = (offsetY + pointerEvent.calculatePan().y).fastCoerceAtLeast(0f)
} else {
  offsetY = 0f
  continue
}
...

看看效果:

twoFingerPullDown.gif

通过这两个例子,相信你已经大致理解 Compose 自定义手势的写法了。

关于 PointerEvent 的其他 API

为了文章的完整性,我们最后再学习一下关于 PointerEvent 和 AwaitPointerEventScope 的其他便捷 API。

我们已经知道在 Android View 的 onTouchEvent(e: MotionEvent) 方法里,只能被动地拿到 MotionEvent,而在 Compose 里面,由于我们是挂起等待事件,除了使用 awaitPointerEvent() 等待任意类型的 PointerEvent 之外,我们还有不少其他 API,用于等待其他特定事件:

AwaitPointerEventScope (拓展)函数 / 属性返回值 / 类型作用
awaitFirstDown()PointerInputChange等待第一根指针的按下事件
waitForUpOrCancellation()PointerInputChange?等待抬起或取消事件
awaitDragOrCancellation() / awaitHorizontalDragOrCancellation() / awaitVerticalDragOrCancellation()PointerInputChange?等待给定指针的单次拖动或取消事件
drag() / horizontalDrag() / verticalDrag()Boolean持续检测给定指针的拖动事件
awaitTouchSlopOrCancellation() / awaitHorizontalTouchSlopOrCancellation() / awaitVerticalTouchSlopOrCancellation()PointerInputChange?等待给定指针的有效触摸事件或取消事件
currentEventPointerEvent(属性)获取已发生的最新事件
withTimeoutOrNull() / withTimeoutOrNull()T / T?
awaitLongPressOrCancellation()PointerInputChange?等待给定指针的长按或取消事件
awaitFirstDown()

等待获取第一根指针的按下事件。按道理来说,组件拿到的第一个事件就是按下事件,所以第一行写 awaitPointerEvent() 获取的就是第一根指针的按下事件,那么为什么会存在 awaitFirstDown() 这个函数?从源码上看,似乎是为了考虑事件消费以及多根指针同时按下的情况。

// TapGestureDetector.kt
suspend fun AwaitPointerEventScope.awaitFirstDown(
  requireUnconsumed: Boolean = true,
  pass: PointerEventPass = PointerEventPass.Main,
): PointerInputChange {
  var event: PointerEvent
  do { event = awaitPointerEvent(pass) } 
  while (
    !event.changes.fastAll {
      if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
    }
  )
  return event.changes[0]
}
waitForUpOrCancellation()

等待 “所有指针抬起” 或 “任一指针取消”。前者返回 PointerInputChange,后者返回 null

// TapGestureDetector.kt
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(
  pass: PointerEventPass = PointerEventPass.Main
): PointerInputChange? {
  while (true) {
    val event = awaitPointerEvent(pass)
    // 所有指针都抬起
    if (event.changes.fastAll { it.changedToUp() }) {
      return event.changes[0]
    }

    // 任一事件被消耗或指针出界,视为取消(Cancel),返回 null
    if (event.changes.fastAny {
      it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
    }) {
      return null
    }

    // Check for cancel by position consumption. We can look on the Final pass of the
    // existing pointer event because it comes after the pass we checked above.
    val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
    if (consumeCheck.changes.fastAny { it.isConsumed }) {
      return null
    }
  }
}
awaitDragOrCancellation()

等待某根给定指针的拖动事件(PointerInputChange)或取消事件(null)。

// DragGestureDetector.kt
suspend fun AwaitPointerEventScope.awaitDragOrCancellation(
  pointerId: PointerId, // 需要指定某根指针
): PointerInputChange? {
  // 指针在一开始就没有处于按下状态,手势取消,直接返回 null
  if (currentEvent.isPointerUp(pointerId)) {
    return null 
  }
  // 使用 awaitDragOrUp() 获取指针的移动或抬起事件
  val change = awaitDragOrUp(pointerId) { it.positionChangedIgnoreConsumed() }
  return if (change?.isConsumed == false) change else null
}

private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
  changes.fastFirstOrNull { it.id == pointerId }?.pressed != true

private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
  pointerId: PointerId, // 给定指针 ID
  hasDragged: (PointerInputChange) -> Boolean // 判断指针拖动的条件
): PointerInputChange? {
  var pointer = pointerId // 追踪的指针 ID
  while (true) {
    // 不断获取指针的最新事件
    val event = awaitPointerEvent()
    val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null // 已按下的指针在抬起之前被丢弃了(出错),直接返回 null
    if (dragEvent.changedToUpIgnoreConsumed()) { // 追踪的指针被抬起了
      val otherDown = event.changes.fastFirstOrNull { it.pressed } // 获取另一根正按着的指针
      if (otherDown == null) { // 追踪的指针抬起了,而且没有其他指针按在屏幕上,返回最后的抬起事件
        return dragEvent
      } else { // 追踪的指针抬起了,换另一根正按在屏幕上的指针,继续追踪(判断拖动)
        pointer = otherDown.id
      }
    } else if (hasDragged(dragEvent)) { // 追踪的指针发生了拖动,直接返回拖动事件 dragEvent
      return dragEvent
    }
  }
}

由源码可以发现,该函数会等待给定指针的单次拖动或取消事件,如果指针被抬起,则会等待另一根正处于按下状态的指针的拖动事件;如果指针被抬起且没有其他指针按在屏幕上,则会返回最后的抬起事件。

awaitHorizontalDragOrCancellation()awaitVerticalDragOrCancellation() 同理,不再赘述。

drag()

不断检测给定指针的拖动事件,直到所有指针都正常抬起(返回 true)或者任一指针被取消(返回 false)。

从源码发现内部在一个死循环内不断调用 awaitDragOrCancellation(),所以如果拖动时给定的指针被抬起,会换另一根正按下的指针继续检测拖动,直到所有指针被抬起(返回 true),这期间如果产生了取消事件,则返回 false。

// DragGestureDetector.kt
suspend fun AwaitPointerEventScope.drag(
  pointerId: PointerId,
  onDrag: (PointerInputChange) -> Unit
): Boolean {
  var pointer = pointerId // 追踪的指针
  while (true) {
    // awaitDragOrCancellation 返回值:
    //   - 不为 null 说明产生了单次拖动,继续循环;
    //   - 为 null 说明产生了取消事件,此时直接返回 false
    val change = awaitDragOrCancellation(pointer) ?: return false

    // 最后的抬起事件,返回 true
    if (change.changedToUpIgnoreConsumed()) {
      return true
    }

    onDrag(change) // 调用拖动回调
    pointer = change.id // 更新追踪的指针
  }
}

horizontalDrag()verticalDrag() 同理,不再赘述。

awaitTouchSlopOrCancellation()

slop [slɒp],“溢出”、“溅出”,在触摸事件处理中,“slop” 是阈值的意思。我们都知道,传感器是极其灵敏的,手指的轻微移动都会触发 Move 事件,可能直觉上我们感觉手指按在屏幕上没动,但却已经触发了拖动手势。为了更好的拖动(滚动)交互体验,我们应该先判断用户的手指移动是否超过了一定的阈值,超过了阈值意味着产生了一次“有效触摸事件”,用户主观上是想进行交互的,这时组件才应该响应用户操作;而如果手指移动距离没有超过阈值,则意味着用户手指只是轻微晃动了一下,此时组件不应响应用户操作。

awaitTouchSlopOrCancellation() 的作用就是等待给定指针的 有效触摸事件 或 取消事件。

suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation(
    pointerId: PointerId,
    onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit
): PointerInputChange?

函数参数 onTouchSlopReached() 会在超过 ViewConfiguration 中所设定的阈值 touchSlop 时回调,如果希望接收这次手势事件,则应该调用 change.consume() 进行消费,此时 awaitTouchSlopOrCancellation() 会返回我们消费的 PointerInputChange,函数执行完毕,协程恢复;如果不消费 change,则会继续挂起检测触摸位移,再次回调累加过的位移。换句话说,事件到底算不算”有效“,是由开发者来决定的,开发者消费了 PointerInputChange 才算“有效触摸事件”,此时 awaitTouchSlopOrCancellation() 函数才执行完毕。

  • drag() 类似,如果给定指针抬起了,此时还有其他指针按在屏幕上,则会选择其中一根手指来继续追踪触摸偏移。

  • 当所有指针都抬起,或者产生了取消事件,则返回 null

awaitHorizontalTouchSlopOrCancellation()awaitVerticalTouchSlopOrCancellation() 同理,不再赘述。

currentEvent

可以通过该属性 currentEvent 获取目前最新的事件。

awaitPointerEvent() 函数是挂起等待一个新的还未发生的事件,currentEvent 属性是直接获取已经发生的最新事件。

interface AwaitPointerEventScope : Density {
  val currentEvent: PointerEvent
}
withTimeout() / withTimeoutOrNull()

如果代码块没有在给定时间内执行完毕,则抛出 PointerEventTimeoutCancellationException

interface AwaitPointerEventScope : Density {
  suspend fun <T> withTimeout(
    timeMillis: Long, // 指定时间
    block: suspend AwaitPointerEventScope.() -> T // 代码块
  ): T = block()
  
  suspend fun <T> withTimeoutOrNull(
    timeMillis: Long,
    block: suspend AwaitPointerEventScope.() -> T
  ): T? = block()
}

这个方法一般用于需要一定时间才触发手势操作的场景,在下面的 awaitLongPressOrCancellation() 方法源码里可以看见。

awaitLongPressOrCancellation

等待给定手指的按下或取消事件。

suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
  pointerId: PointerId
): PointerInputChange? {
  // 在一开始,相应的手指根本没有处于按下状态,直接返回 null(长按手势取消)
  if (currentEvent.isPointerUp(pointerId)) {
    return null
  }
  
  // 最初的按下事件(如果当前找不到对应手指,也直接返回 null)
  val initialDown =
    currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null

  var longPress: PointerInputChange? = null // 长按事件
  var currentDown = initialDown             // 当前跟踪的那根手指的按下事件
  val longPressTimeout = viewConfiguration.longPressTimeoutMillis // 长按时间
  return try {
    // wait for first tap up or long press
    withTimeout(longPressTimeout) { // 如果已经过去给定时间,代码块还没执行完毕,则抛出错误
      var finished = false
      // 开一个无限循环,在长按时间内,退出循环会返回 null
      while (!finished) {
        val event = awaitPointerEvent(PointerEventPass.Main)
               
        // 所有手指都已经抬起了,结束循环(还没到达长按时间,所有手指就都抬起了,返回 null 取消长按)
        if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) {
          finished = true
        }

        // 事件被消费或者手指出界,结束循环(还没到达长按时间,事件被消费或者手指出界,返回 null 取消长按)
        if (
          event.changes.fastAny {
            it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
          }
        ) {
          finished = true // Canceled
        }
        // Check for cancel by position consumption. We can look on the Final pass of
        // the existing pointer event because it comes after the Main pass we checked
        // above.
        val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
        if (consumeCheck.changes.fastAny { it.isConsumed }) {
          finished = true
        }
                
        if (event.isPointerUp(currentDown.id)) { // 当前跟踪的手指被抬起了
          // 换另一根正按着的手指
          val newPressed = event.changes.fastFirstOrNull { it.pressed }
          if (newPressed != null) { 
            currentDown = newPressed // 为了下一轮循环而赋值(更新追踪的手指)
            longPress = currentDown // 将新手指的事件赋值给 longPress,达到长按时间后再返回
          } else {
            // 没有任何手指正按着,理论上不会发生,因为上面已经检查过了
            finished = true
          }
        } else { // 当前跟踪的手指还按着
          // 将该手指的事件赋值给 longPress,达到长按时间后再返回
          longPress = event.changes.fastFirstOrNull { it.id == currentDown.id }
        }
      }
    }
    null
  } catch (_: PointerEventTimeoutCancellationException) {
    // 长按时间到了,有长按就返回长按事件,否则返回最初按下事件
    longPress ?: initialDown
  }
}

private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
  changes.fastFirstOrNull { it.id == pointerId }?.pressed != true

从源码不难看出,如果长按期间内给定的指针抬起了,但抬起时有其他指针处于按下状态,长按监测不会中断,而是会换一根指针继续监测长按。

关于 PointerEvent 的其他 API
PointerEvent 拓展函数返回值作用
calculateCentroid()Offset计算指针的几何中心
calculatePan()Offset计算指针几何中心点相对上一刻的位移
calculateCentroidSize()Float计算所有指针位置距离几何中心点的平均距离
calculateZoom()Float计算多指相对上一刻的缩放比例
calculateRotation()Float计算多指相对上一刻的角度变化
calculateCentroid()

计算指针的几何中心。

// TransformGestureDetector.kt
fun PointerEvent.calculateCentroid(
  useCurrent: Boolean = true // 是否使用当前一刻的手指位置
): Offset {
  var centroid = Offset.Zero
  var centroidWeight = 0

  changes.fastForEach { change ->
    if (change.pressed && change.previousPressed) { // 这一刻和上一刻都是按着的
      val position = if (useCurrent) change.position else change.previousPosition
      centroid += position
      centroidWeight++
    }
  }
  return if (centroidWeight == 0) {
    Offset.Unspecified
  } else {
    centroid / centroidWeight.toFloat()
  }
}

Centroid 是几何学中的术语,表示“几何中心”、“形心”、“重心”,相较于 Center 表达的意思更加准确。

calculatePan()

计算指针几何中心点相对上一刻的位移。

// TransformGestureDetector.kt
fun PointerEvent.calculatePan(): Offset {
  val currentCentroid = calculateCentroid(useCurrent = true)
  if (currentCentroid == Offset.Unspecified) {
    return Offset.Zero
  }
  val previousCentroid = calculateCentroid(useCurrent = false)
  return currentCentroid - previousCentroid
}

Pan 这个术语在 UI 设计和图形处理中通常用于描述用户在视图或画布上拖动的动作。

calculateCentroidSize()

计算所有指针位置距离几何中心点的平均距离。

// TransformGestureDetector.kt
fun PointerEvent.calculateCentroidSize(useCurrent: Boolean = true): Float {
  val centroid = calculateCentroid(useCurrent)
  if (centroid == Offset.Unspecified) {
    return 0f
  }

  var distanceToCentroid = 0f
  var distanceWeight = 0
  changes.fastForEach { change ->
    if (change.pressed && change.previousPressed) {
      val position = if (useCurrent) change.position else change.previousPosition
      distanceToCentroid += (position - centroid).getDistance()
      distanceWeight++
    }
  }
  return distanceToCentroid / distanceWeight.toFloat()
}
calculateZoom()

计算多指相对上一刻的缩放比例。

// TransformGestureDetector.kt
fun PointerEvent.calculateZoom(): Float {
  val currentCentroidSize = calculateCentroidSize(useCurrent = true)
  val previousCentroidSize = calculateCentroidSize(useCurrent = false)
  if (currentCentroidSize == 0f || previousCentroidSize == 0f) {
    return 1f
  }
  return currentCentroidSize / previousCentroidSize
}
calculateRotation()

计算多指相对上一刻的角度变化,代码不贴了,👈 有兴趣可以自己翻翻看,不难。

下期预告

嵌套滑动,咕咕咕 🕊🕊🕊


参考/关联阅读: