使用Jetpack Compose完成自定义手势处理,android开发技术难点

onGesture(必须):当拖动、缩放或旋转手势发生时回调

suspend fun PointerInputScope.detectTransformGestures(

panZoomLock: Boolean = false,

onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit

)

Tips

关于偏移、缩放与旋转,我们建议的调用顺序是 rotate -> scale -> offset

  1. 若offset发生在rotate之前时,rotate会对offset造成影响。具体表现为当出现拖动手势时,组件会以当前角度为坐标轴进行偏移。
  1. 若offset发生在scale之前是,scale也会对offset造成影响。具体表现为UI组件在拖动时不跟手

@Preview

@Composable

fun TransformGestureDemo() {

var boxSize = 100.dp

var offset by remember { mutableStateOf(Offset.Zero) }

var ratationAngle by remember { mutableStateOf(0f) }

var scale by remember { mutableStateOf(1f) }

Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {

Box(Modifier

.size(boxSize)

.rotate(ratationAngle) // 需要注意offset与rotate的调用先后顺序

.scale(scale)

.offset {

IntOffset(offset.x.roundToInt(), offset.y.roundToInt())

}

.background(Color.Green)

.pointerInput(Unit) {

detectTransformGestures(

panZoomLock = true, // 平移或放大时是否可以旋转

onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->

offset += pan

scale *= zoom

ratationAngle += rotation

}

)

}

)

}

}

forEachGesture

在传统 View 系统中,一次手指按下、移动到抬起过程中的所有手势事件可以共同构成一个手势事件序列。我们可以通过自定义手势处理来对于每一个手势事件序列进行定制处理。Compose 提供了 forEachGesture 以允许用户可以对每一个手势事件序列进行相同的定制处理。如果我们忘记使用 forEachGesture ,那么只会处理第一次手势事件序列。有些同学可能会问,为什么我不能在手势处理逻辑最外层套一层 while(true) 呢,通过 forEachGesture 的实现我们可以看到 forEachGesture 其实内部也是由while 实现的,除此之外他保证了协程只有存活时才能监听手势事件,同时也保证了每次交互结束时所有手指都是离开屏幕的。有些同学看到 while 可能新生疑问,难道这样不会阻塞主线程嘛?其实我们在介绍 PointerInput Modifier 时就提到过,我们的手势操作处理均发生在协程中。其实前面我们所提到的绝大多数 API 其内部实现均使用了 forEachGesture 。有些特殊场景下我们仅使用前面所提出的 API 可能仍然无法满足我们的需求,当然如果可以满足的话我们直接使用其分别对应的 Modifier 即可,前面所提出的 API 存在的意义是为了方便开发者为其进行功能拓展。既然要掌握自定义手势处理,我们就要从更底层角度来看这些上层 API 是如何实现的,了解原理我们就可以轻松自定义了。

suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {

val currentContext = currentCoroutineContext()

while (currentContext.isActive) {

try {

block()

// 挂起等待所有手指抬起

awaitAllPointersUp()

} catch (e: CancellationException) {

}

}

}

手势事件作用域 awaitPointerEventScope


PointerInputScope 中我们可以找到一个名为 awaitPointerEventScope 的 API 方法。

通过翻阅方法声明可以发现这是个挂起方法,其尾部 lambda 在 AwaitPointerEventScope 作用域中。 通过这个 AwaitPointerEventScope 作用域我们可以获取到更加底层的 API 手势事件,这也为自定义手势处理提供了可能。

suspend fun awaitPointerEventScope(

block: suspend AwaitPointerEventScope.() -> R

): R

我们在 AwaitPointerEventScope 中发现了以下这些基础手势方法,可以发现这些 API 均是挂起函数,接下来我们会对每个 API 进行描述说明。

| API名称 | 作用 |

| — | — |

| awaitPointerEvent | 手势事件 |

| awaitFirstDown | 第一根手指的按下事件 |

| drag | 拖动事件 |

| horizontalDrag | 水平拖动事件 |

| verticalDrag | 垂直拖动事件 |

| awaitDragOrCancellation | 单次拖动事件 |

| awaitHorizontalDragOrCancellation | 单次水平拖动事件 |

| awaitVerticalDragOrCancellation | 单次垂直拖动事件 |

| awaitTouchSlopOrCancellation | 有效拖动事件 |

| awaitHorizontalTouchSlopOrCancellation | 有效水平拖动事件 |

| awaitVerticalTouchSlopOrCancellation | 有效垂直拖动事件 |

万物之源 awaitPointerEvent

awaitPointerEvent 类似于传统 View 系统的 onTouchEvent() 。无论用户是按下、移动或抬起都将视作一次手势事件,当手势事件发生时 awaitPointerEvent 会恢复执行并将手势事件返回。

suspend fun awaitPointerEvent(

pass: PointerEventPass = PointerEventPass.Main

): PointerEvent

通过 API 声明可以看到 awaitPointerEvent 有个可选参数 PointerEventPass

我们知道手势事件的分发是由父组件到子组件的单链结构。这个参数目的是用以设置父组件与子组件的事件分发顺序,PointerEventPass 有 3 个枚举值可供选择,每个枚举值的具体含义如下

| 枚举值 | 含义 |

| — | — |

| PointerEventPass.Initial | 本组件优先处理手势,处理后交给子组件 |

| PointerEventPass.Main | 若子组件为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。 |

| PointerEventPass.Final | 若子组件也为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。 |

大家可能觉得 Main 与 Final 是等价的。但其实两者在作为子组件时分发顺序会完全不同,举个例子。

当父组件为Final,子组件为Main时,事件分发顺序: 子组件 -> 父组件

当父组件为Final,子组件为Final时,事件分发顺序: 父组件 -> 子组件

文字描述可能并不直观,接下来进行举例说明。

事件分发流程

接下来,我将通过一个嵌套了三层 Box 的示例来直观表现事件分发过程。我们为这嵌套的三层Box 中的每一层都进行手势获取。

使用Jetpack Compose完成自定义手势处理,android开发技术难点_第1张图片

如果我们点击中间的绿色方块时,便会触发手势事件。

当三层 Box 均使用默认 Main 模式时,事件分发顺序为:第三层 -> 第二层 -> 第一层

当第一层Box使用 Inital 模式,第二层使用 Final 模式,第三层使用 Main 模式时,事件分发顺序为:第一层 -> 第三层 -> 第二层

@Preview

@Composable

fun NestedBoxDemo() {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.fillMaxSize()

.pointerInput(Unit) {

awaitPointerEventScope {

awaitPointerEvent(PointerEventPass.Initial)

Log.d(“compose_study”, “first layer”)

}

}

) {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.size(400.dp)

.background(Color.Blue)

.pointerInput(Unit) {

awaitPointerEventScope {

awaitPointerEvent(PointerEventPass.Final)

Log.d(“compose_study”, “second layer”)

}

}

) {

Box(

Modifier

.size(200.dp)

.background(Color.Green)

.pointerInput(Unit) {

awaitPointerEventScope {

awaitPointerEvent()

Log.d(“compose_study”, “third layer”)

}

}

)

}

}

}

// Output:

// first layer

// third layer

// second layer

能够自定义事件分发顺序之后,我们就可以决定手势事件由事件分发流程中哪个组件进行消费。那么如何进行消费呢,这就需要我们看看 awaitPointerEvent 返回的手势事件了。通过 awaintPointerEvent 声明,我们可以看到返回的手势事件是个 PointerEvent 实例。

通过 PointerEvent 类声明,我们可以看到两个成员属性 changes 与 motionEvent。

motionEvent 我们再熟悉不过了,就是传统 View 系统中的手势事件,然而却被声明了 internal 关键字,看来是不希望我们使用。

changes 是一个 List,其中包含了每次发生手势事件时,屏幕上所有手指的状态信息。

当只有一根手指时,这个 List 的大小为 1。在多指操作时,我们通过这个 List 获取其他手指的状态信息就可以轻松定制多指自定义手势处理了。

actual data class PointerEvent internal constructor(

actual val changes: List,

internal val motionEvent: MotionEvent?

)

PointerInputChange

class PointerInputChange(

val id: PointerId, // 手指Id

val uptimeMillis: Long, // 当前手势事件的时间戳

val position: Offset, // 当前手势事件相对组件左上角的位置

val pressed: Boolean, // 当前手势是否按下

val previousUptimeMillis: Long, // 上一次手势事件的时间戳

val previousPosition: Offset, // 上一次手势事件相对组件左上角的位置

val previousPressed: Boolean, // 上一次手势是否按下

val consumed: ConsumedData, // 当前手势是否已被消费

val type: PointerType = PointerType.Touch // 手势类型(鼠标、手指、手写笔、橡皮)

)

| API名称 | 作用 |

| — | — |

| changedToDown | 是否已经按下(按下手势已消费则返回false) |

| changedToDownIgnoreConsumed | 是否已经按下(忽略按下手势已消费标记) |

| changedToUp | 是否已经抬起(按下手势已消费则返回false) |

| changedToUpIgnoreConsumed | 是否已经抬起(忽略按下手势已消费标记) |

| positionChanged | 是否位置发生了改变(移动手势已消费则返回false) |

| positionChangedIgnoreConsumed | 是否位置发生了改变(忽略已消费标记) |

| positionChange | 位置改变量(移动手势已消费则返回Offset.Zero) |

| positionChangeIgnoreConsumed | 位置改变量(忽略移动手势已消费标记) |

| positionChangeConsumed | 当前移动手势是否已被消费 |

| anyChangeConsumed | 当前按下手势或移动手势是否有被消费 |

| consumeDownChange | 消费按下手势 |

| consumePositionChange | 消费移动手势 |

| consumeAllChanges | 消费按下与移动手势 |

| isOutOfBounds | 当前手势是否在固定范围内 |

这些 API 会在我们自定义手势处理时会被用到。可以发现的是,Compose 通过 PointerEventPass 来定制事件分发流程,在事件分发流程中即使前一个组件先获取了手势信息并进行了消费,后面的组件仍然可以通过带有 IgnoreConsumed 系列 API 来获取到手势信息。这也极大增加了手势操作的可定制性。就好像父组件先把事件消费,希望子组件不要处理这个手势了,但子组件完全可以不用听从父组件的话。

我们通过一个实例来看看该如何进行手势消费,处于方便我们的示例不涉及移动,只消费按下手势事件来进行举例。和之前的样式一样,我们将手势消费放在了第三层 Box,根据事件分发规则我们知道第三层Box是第2个处理手势事件的,所以输出结果如下。

@Preview

@Composable

fun Demo() {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.fillMaxSize()

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent(PointerEventPass.Initial)

Log.d(“compose_study”, “first layer, downChange: ${event.changes[0].consumed.downChange}”)

}

}

) {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.size(400.dp)

.background(Color.Blue)

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent(PointerEventPass.Final)

Log.d(“compose_study”, “second layer, downChange: ${event.changes[0].consumed.downChange}”)

}

}

) {

Box(

Modifier

.size(200.dp)

.background(Color.Green)

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent()

event.changes[0].consumeDownChange()

Log.d(“compose_study”, “third layer, downChange: ${event.changes[0].consumed.downChange}”)

}

}

)

}

}

}

// Output:

// first layer, downChange: false

// third layer, downChange: true

// second layer, downChange: true

⚠️ 注意事项

如果我们是在定制事件分发流程,那么需要注意以下两种写法

// 正确写法

awaitPointerEventScope {

var event = awaitPointerEvent()

event.changes[0].consumeDownChange()

}

// 错误写法

var event = awaitPointerEventScope {

awaitPointerEvent()

}

event.changes[0].consumeDownChange()

他们的区别在于 awaitPointerEventScope 会在其内部所有手势在事件分发流程结束后返回,当所有组件都已经完成手势处理再进行消费已经没有什么意义了。我们仍然用刚才的例子来直观说明这个问题。我们在每一层Box awaitPointerEventScope 后面添加了日志信息。

通过输出结果可以发现,这三层执行的相对顺序没有发生变化,然而却是在事件分发流程结束后才进行输出的。

@Preview

@Composable

fun Demo() {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.fillMaxSize()

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent(PointerEventPass.Initial)

Log.d(“compose_study”, “first layer, downChange: ${event.changes[0].consumed.downChange}”)

}

Log.d(“compose_study”, “first layer Outside”)

}

) {

Box(

contentAlignment = Alignment.Center,

modifier = Modifier

.size(400.dp)

.background(Color.Blue)

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent(PointerEventPass.Final)

Log.d(“compose_study”, “second layer, downChange: ${event.changes[0].consumed.downChange}”)

}

Log.d(“compose_study”, “second layer Outside”)

}

) {

Box(

Modifier

.size(200.dp)

.background(Color.Green)

.pointerInput(Unit) {

awaitPointerEventScope {

var event = awaitPointerEvent()

event.changes[0].consumeDownChange()

Log.d(“compose_study”, “third layer, downChange: ${event.changes[0].consumed.downChange}”)

}

Log.d(“compose_study”, “third layer Outside”)

}

)

}

}

}

// Output:

// first layer, downChange: false

// third layer, downChange: true

// second layer, downChange: true

// first layer Outside

// third layer Outside

// second layer Outside

awaitFirstDown

awaitFirstDown 将等待第一根手指按下事件时恢复执行,并将手指按下事件返回。分析源码我们可以发现 awaitFirstDown 也使用的是 awaitPointerEvent 实现的,默认使用 Main 模式。

suspend fun AwaitPointerEventScope.awaitFirstDown(

requireUnconsumed: Boolean = true

): PointerInputChange {

var event: PointerEvent

do {

event = awaitPointerEvent()

} while (

!event.changes.fastAll {

if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()

}

)

return event.changes[0]

}

drag

看到 drag 可能很多同学疑惑为什么又是拖动。其实前面所提到的拖动类型基础API detectDragGestures 其内部就是使用 drag 而实现的。与 detectDragGestures 不同的是,drag 需要主动传入一个 PointerId 用以表示要具体获取到哪根手指的拖动事件。

suspend fun AwaitPointerEventScope.drag(

pointerId: PointerId,

onDrag: (PointerInputChange) -> Unit

)

翻阅源码可以发现,其实 drag 内部实现最终使用的仍然还是 awaitPointerEvent 。这里就不具体展开看了,感兴趣的可以自己去跟源码。

举例说明

通过结合 awaitFirstDowndrag 这些基础 API 我们已经可以自己实现 UI 拖动手势流程了。我们仍然以我们的绿色方块作为实例,为其添加拖动手势。

@Preview

@Composable

fun BaseDragGestureDemo() {

var boxSize = 100.dp

var offset by remember { mutableStateOf(Offset.Zero) }

Box(contentAlignment = Alignment.Center,

modifier = Modifier.fillMaxSize()

) {

Box(Modifier

.size(boxSize)

.offset {

IntOffset(offset.x.roundToInt(), offset.y.roundToInt())

}

.background(Color.Green)

.pointerInput(Unit) {

forEachGesture { // 循环监听每一组事件序列

awaitPointerEventScope {

var downEvent = awaitFirstDown()

drag(downEvent.id) {

offset += it.positionChange()

}

}

}

}

)

}

}

awaitDragOrCancellation

drag 不同的是,awaitDragOrCancellation 负责监听单次拖动事件。当手指已经抬起或拖动事件已经被消费时会返回 null。当然我们也可以使用 awaitDragOrCancellation 来完成 UI 拖动手势处理流程。通过翻阅源码可以发现 drag 其实内部也是使用 awaitDragOrCancellation 进行实现的。而 awaitDragOrCancellation 内部仍然是 awaitPointerEvent

@Preview

@Composable

fun BaseDragGestureDemo() {

var boxSize = 100.dp

var offset by remember { mutableStateOf(Offset.Zero) }

Box(contentAlignment = Alignment.Center,

modifier = Modifier.fillMaxSize()

) {

Box(Modifier

.size(boxSize)

.offset {

IntOffset(offset.x.roundToInt(), offset.y.roundToInt())

}

.background(Color.Green)

.pointerInput(Unit) {

forEachGesture {

awaitPointerEventScope {

var downPointer = awaitFirstDown()

while (true) {

使用Jetpack Compose完成自定义手势处理,android开发技术难点_第2张图片

更多学习和讨论,欢迎加入我们的知识星球!

点击这里加入我们吧!

群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的Android交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

ion内部仍然是awaitPointerEvent`。

@Preview

@Composable

fun BaseDragGestureDemo() {

var boxSize = 100.dp

var offset by remember { mutableStateOf(Offset.Zero) }

Box(contentAlignment = Alignment.Center,

modifier = Modifier.fillMaxSize()

) {

Box(Modifier

.size(boxSize)

.offset {

IntOffset(offset.x.roundToInt(), offset.y.roundToInt())

}

.background(Color.Green)

.pointerInput(Unit) {

forEachGesture {

awaitPointerEventScope {

var downPointer = awaitFirstDown()

while (true) {

[外链图片转存中…(img-qZKWPxsm-1646148047855)]

更多学习和讨论,欢迎加入我们的知识星球!

点击这里加入我们吧!

群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的Android交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

这里有2000+小伙伴,让你的学习不寂寞~·

你可能感兴趣的:(程序员,面试,移动开发,android)