文章目录
一维滑动监测和嵌套滑动
在传统的自定义 View 我们写触摸反馈一般是重写 onTouchEvent(),如果是 ViewGroup 还可能会需要重写 onInterceptTouchEvent(),还要更深度定制的话可能还会需要重写 dispatchTouchEvent() 等:
class MyView(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
return super.onTouchEvent(event)
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
return super.onInterceptTouchEvent(ev)
}
}
到了 Compose 如果要定制自定义的触摸反馈,可以使用 PointerInputModifier。不过这里先不细讲如何深度定制化触摸反馈,我们先从滑动和拖动讲起。
在 Compose 滑动是使用 Modifier.scrollable(),拖拽拖动是使用 Modifier.draggable(),从场景上它们是一样的都属于滑动监听,实际上 Modifier.scrollable() 也是用的 Modifier.draggable() 实现,Modifier.draggable() 是更下层的实现。
Modifier.draggable()
在 Compose 中所有可操作的组件(或者说 Modifier)都会提供一个 XxxState 用于操作组件,比如列表 LazyColumn 就是提供了 LazyListState:
setContent {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
items(listOf(1, 2, 3)) {
Text("Number: $it")
}
}
val scope = rememberCoroutineScope()
Button(onClick = {
// 通过 State 操作列表组件
scope.launch { listState.animateScrollToItem(2) }
}) {
}
}
Modifier.draggable() 也是有自己的 State 为 DraggableState,可以通过它监听手指一维的拖拽的像素,比如 x 轴或 y 轴的像素位置,指定方向通过 Orientation 参数设置:
Draggable.kt
fun Modifier.draggable(
state: DraggableState, // 获取拖拽的像素位置
orientation: Orientation, // 监控的一维方向,x 轴或 y 轴
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 = draggable(
...
)
setContent {
Text("test", Modifier.draggable(rememberDraggableState {
println("又移动了 $it 个像素")
}, Orientation.Horizontal))
}
除了 DraggableState 和 Orientation,Modifier.draggable() 还提供了其他的参数:
-
enabled:控制是否允许拖拽,能更方便的动态控制是否允许拖拽
-
interactionSource:在 Modifier.draggable() 所修饰的范围进行触摸相关的状态监控:
setContent {
Column {
val interactionSource = remember { MutableInteractionSource() }
Text("test", Modifier.draggable(rememberDraggableState {
println("又移动了 $it 个像素")
}, Orientation.Horizontal, interactionSource = interactionSource))
// 通过 interactionSource 监控拖拽显示不同的状态
val isDragged by interactionSource.collectIsDraggedAsState()
Text(if (isDragged) "拖动中" else "静止")
}
}
-
startDragImmediately:是否点击是就开始算拖拽还是拖动一段距离后才算拖拽
-
onDragStarted/onDragStopped:监听拖拽开始和结束的处理
-
reverseDirection:是否要返回与拖拽相反的处理
为了能更好的理解怎么使用 Modifier.draggable(),我们简单实现一个拖动文字的效果。
setContent {
var offsetX by remember { mutableStateOf(0f) }
Text("test",
Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(rememberDraggableState {
println("又移动了 $it 个像素")
offsetX += it // 拖动时修改文字偏移位置实现拖动文字效果
}, Orientation.Horizontal)
)
}
Modifier.scrollable()
Modifier.scrollable() 和 Modifier.verticalScroll() 与 Modifier.horizontalScroll() 是不同的:
-
Modifier.verticalScroll() 和 Modifier.horizontalScroll() 是用于增加滑动功能的,你可以理解为类似于对一个传统的 View 套上一个 ScrollView,让 View 具有了滑动的功能
-
Modifier.scrollable() 只是一个滑动监测的功能,它并不提供任何画面相关的处理
当然,从底层角度分析 Modifier.verticalScroll() 和 Modifier.horizontalScroll() 也是使用 Modifier.scrollable() 监听滑动。
相比 Modifier.draggable(),Modifier.scrollable() 增加了三点功能:
-
惯性滑动
-
嵌套滑动
-
滑动触边效果(overscroll)
Scrollable.kt
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
overscrollEffect: OverscrollEffect?, // 处理滑动触边效果
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null, // 处理惯性滑动
interactionSource: MutableInteractionSource? = null
): Modifier = composed(
...
)
setContent {
Modifier.scrollable(rememberScrollableState {
println("又滚动了 $it 个像素")
// 要返回消费了多少滑动距离,通过返回值处理嵌套滑动
// 这里返回 it 表示你给的滑动距离在该组件全部消费,父组件能收到的滑动距离会是 0
// 比如这里如果想让父组件全消费,那就填写 0f
it
}, Orientation.Horizontal)
}
和 Modifier.draggable() 提供的 DraggableState 类似,Scrollable 也提供了 ScrollableState 用于操作组件。
除此之外还有 惯性滑动 FlingBehavior 和滑动触边效果 OverscrollEffect,如果不传默认都有数值:
setContent {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val flingBehavior = ScrollableDefaults.flingBehavior()
Modifier.scrollable(rememberScrollableState {
println("又滚动了 $it 个像素")
it
}, Orientation.Horizontal, flingBehavior = flingBehavior, overscrollEffect = overscrollEffect)
}
Modifier.nestedScroll()
在原生 View 系统支持嵌套滑动的组件有 RecyclerView、NestedScrollView 等,这些组件都归属在 Jetpack 下可以单独引入,不需要等到系统升级上来才做更新;Compose 同样的也是归属于 Jetpack,组件对嵌套滑动的支持也是很完善的,比如 LazyColumn、LazyRow 等其实都支持嵌套滑动。
setContent {
NestedScrollDemo()
}
@Composable
private fun NestedScrollDemo() {
LazyColumn(Modifier.fillMaxSize()) {
item {
LazyColumn(
Modifier
.fillMaxWidth()
.height(250.dp)
.background(Color.Red)
) {
items(8) {
Text(
"第一部分:${it + 1}",
Modifier
.fillMaxWidth()
.padding(8.dp), fontSize = 24.sp, textAlign = TextAlign.Center
)
}
}
}
items(20) {
Text(
"第二部分:${it + 1}",
Modifier
.fillMaxWidth()
.padding(8.dp), fontSize = 24.sp, textAlign = TextAlign.Center
)
}
}
}
上面的 demo 是列表嵌套滑动的效果,可以看到嵌套的 LazyColumn 会先处理触摸事件滑动,等滑动到底部的时候才将后续的滑动处理交给外部的 LazyColumn。
如果在实际项目中只是这种场景我们确实可以直接使用组件就行了,不需要自己额外处理。
当然如果是比较复杂的嵌套滑动效果,比如下面的效果:
上图的效果是列表下滑到顶部再继续下拉时会将顶部栏放大,列表上滑时会先顶部栏缩回再滑动列表。
Compose 提供了 Scaffold 组件能实现大部分的嵌套滑动场景实现,比如上面的效果也可以实现:
setContent {
Scaffold {
LargeTopAppBar(
title = {},
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
)
}
}
因为嵌套滑动的场景是很多的,总会遇到 Compose 没有提供对应场景的实现,所以这时候就需要我们自己写嵌套滑动了。自定义嵌套滑动使用的 Modifier.nestedScroll():
NestedScrollModifier.kt
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(...) {
...
}
要实现嵌套滑动需要做两件事情:
-
作为子组件在用户滑动的时候去调用父组件的回调:NestedScrollDispatcher
-
作为父组件处理子组件对自己的回调:NestedScrollConnection
setContent {
NestedScrollSample()
}
@Composable
private fun NestedScrollSample() {
var offsetY by remember { mutableStateOf(0f) }
val dispatcher = remember { NestedScrollDispatcher() }
val connection = remember {
// NestedScrollConnection 处理嵌套滑动要做的第二件事情:作为父组件处理子组件对自己的回调
object : NestedScrollConnection {
// 如果要干预子组件调用之前的处理,可以重写 onPreScroll()
// 调用 dispatcher.dispatchPostScroll() 会回调过来
// 处理子组件对自己的回调,也就是处理外层父组件 Column 的滑动
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
offsetY += available.y
return available
}
}
}
Column(
Modifier
.offset { IntOffset(0, offsetY.roundToInt()) }
.draggable(rememberDraggableState {
// dispatchPreScroll() 和 dispatchPostScroll() 处理嵌套滑动要做的第一件事情:作为子组件在用户滑动的时候去调用父组件的回调
// 要注意填入这两个参数的 consumed 参数的数值,该案例是有多少消费多少
// 所以消费的距离传入的都是 Offset(0f, it)
// consumed 是父组件消费掉的滑动距离
val consumed = dispatcher.dispatchPreScroll(Offset(0f, it), NestedScrollSource.Drag)
offsetY += it - consumed.y
dispatcher.dispatchPostScroll(Offset(0f, it), Offset.Zero, NestedScrollSource.Drag)
}, Orientation.Vertical)
.nestedScroll(connection, dispatcher)
) {
for (i in 1..10) {
Text("第 $i 项")
}
LazyColumn(Modifier.height(50.dp)) {
items(5) {
Text("内部 List - 第 $it 项")
}
}
}
}
二维滑动监测
所谓的二维滑动监测指的是不限定 x 轴还是 y 轴方向,而是针对整个平面做滑动监测。
Compose 并没有提供直接的二维滑动监测 API,我们需要自己通过更底层的 API 即 Modifier.pointerInput() 自己做该实现。
DragGestureDetector.kt
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
...
}
-
change:专门用于存储触摸事件中手指的信息,更确切的说是存储触摸点信息
-
dragAmount:Offset 类型,通过它能拿到横纵坐标点位置
除了这几个函数外,detectDragGestures() 还有几个兄弟函数:
多指手势
Compose 提供了移动、放缩和旋转三种多指手势的识别,它们都放在同一个函数 detectTransformGesture() 里面:
TransformGestureDetector.kt
suspend fun PointerInputScope.detectTransformGesture(
panZoomLock: Boolean = false,
onGesuture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
) {
...
}
setContent {
Modifier.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotation ->
}
}
}
-
panZoomLock:默认为 false,如果为 true,多指手势移动、放缩时在识别到其中一种手势后,在手势抬起前就不会同时识别移动旋转和缩放旋转的处理,也让移动缩放手势不会同时识别(因为多指手势时三种方式同时存在是可能的)
-
centroid:Offset 类型,中心点的意思,表示监测到的几个触摸点的中心点,它是一个辅助参数,配合 pan、zoom 和 rotation 使用以哪为中心做转换
-
pan:位移参数,表示这一瞬间和上一瞬间相比的位置偏移量
-
zoom:放缩倍数参数,表示这一瞬间和上一瞬间相比的放缩倍数
-
rotation:旋转的角度,表示这一瞬间和上一瞬间相比的旋转角度
最底层的自定义触摸算法
实现一个点击监听,思路其实很简单,可以只监听抬起事件:
setContent {
Text("test", Modifier.customClick {
Toast.makeText(this, "发生事件: customClick", Toast.LENGTH_SHORT).show()
})
}
private fun Modifier.customClick(onClick: () -> Unit) = pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Release) {
onClick()
}
}
}
}
在 Compose 触摸事件类型主要有三种:
-
PointerEventType.Press:按下事件,对应的 MotionEvent.ACTION_DOWN,多指的 MotionEvent.ACTION_POINTER_DOWN 也是返回它
-
PointerEventType.Release:抬起事件,对应的 MotionEvent.ACTION_UP,多指的 MotionEvent.ACTION_POINTER_UP 也是返回它
-
PointerEventType.Move:移动事件,对应的 MotionEvent.ACTION_MOVE
基于上面的实现再增加需求:在点击到抬起过程中如果移出组件范围就不算点击。
思路:
-
增加一个按下事件的监听,打上一个点击监听开始的标记
-
在按下之后抬起之前这期间,检查每一个移动事件的坐标是否超出了组件范围,如果超出了就认为点击无效
private fun Modifier.customClick(onClick: () -> Unit) = pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown() // 第一个事件肯定是按下事件
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
val pos = event.changes[0].position // 获取一根手指的坐标
// 一超出组件范围就认定点击无效
if (pos.x < 0 || pos.x > size.width || pos.y < 0 || pos.y > size.height) {
break
}
} else if (event.type == PointerEventType.Release && event.changes.size == 1) {
// event.changes.size == 1 排除多指触摸场景
// 不过这种判断并不严谨,判断手指抬起可以参考源码 waitForUpOrCancellation()
onClick()
break;
}
}
}
}
// gif 效果图
触摸事件的消费、拦截和取消。在传统 View 系统的触摸事件消费和拦截,我们以列表和列表条目下的按钮举例。
-
事件的消费:当我们点击按钮的时候,是按钮响应了事件而不是列表,从事件消费的角度是子组件优先的,子组件不消费事件了才交给父组件
-
事件的拦截:手指往上拖动的时候,用户预期是列表滑动,从事件拦截的角度是父组件优先的,判断如果不拦截就交给子组件
在 Compose 这两个概念被统一了,只有事件消费而没有事件拦截,但是处理上和传统 View 系统是一样的,并且多了一次父组件到子组件的过程,多出来的一次为了嵌套滑动提供了基础层面的支持,并且检查做事件的取消。
第一次从父组件到子组件是处理滑动之类的手势拦截,再一次从子组件到父组件是处理各种事件的消费,但是对于嵌套滑动只有以上两次处理是不够的,嵌套滑动是一种两个都想要的特殊需求,比如为了滑动拦截功能需要父组件拿到事件,同时因为是嵌套的,所以还要由最里面的组件触发起的,这样就相当于由需要子组件优先拿到事件,又需要父组件优先拿到事件,这就要求整个事件处理链条上对事件进行消费的是多个滑动组件里面最里面的那个子滑动组件,它是属于这个滑动组件它的子组件和父滑动组件之间的那个位置;所以只有两次事件处理是做不到的。
不过需要注意的是,流程只做到了收到事件的是最里面的那个滑动组件,具体的嵌套滑动的滑动事件传递处理是通过 Modifier.nestedScroll()。
多加的一次流程也处理事件的取消,在 Compose 的前两次消费流程下,不能处理如子组件按钮按下有选中状态,实际是父组件列表要滑动的流程,因为子组件无感知父组件的事件消费会导致松手的时候按钮点击被触发,所以多加一次从父组件到子组件的检查通知子组件取消事件处理。
那么知道了这些概念后应该怎么用 Compose 做触摸事件处理?两个原则:
-
使用完任何事件之后要把它消费掉
-
对每个事件处理前先检查有没有消费过,一般是拿到事件判断没消费过才使用
上面的三个流程在 Compose 提供了三个枚举获取事件处理流程:
PointerEvent.kt
enum class PointerEventPass {
Initial, Main, Final
}
awaitEachGesture {
val event1 = awaitPointerEvent(PointerEventPass.Initial)
val event2 = awaitPointerEvent(PointerEventPass.Main)
val event3 = awaitPointerEvent(PointerEventPass.Final)
}
如果不填写,默认是 Main 表示从子组件到父组件的事件消费流程。
如果你确认要消费这个事件,可以通过调用 consume() 标记:
awaitEachGesture {
val event = awaitPointerEvent()
// 事件标记为已消费
event.changes[0].consume()
// 判断事件是否已消费
if (event.changes[0].isConsumed) {
}
}
如果有多指滑动捏撑的需求,一般计算获取多指之间的中心点位置,Compose 也提供了相应的 API:
awaitEachGesture {
val event = awaitPointerEvent()
event.calculatePan() // 获取多指触摸的中心点坐标位置
}