在 Compose
中, 处理滑动冲突的 Api
是 Modifier.nestedScroll()
下面就让我们来看看 Compose
中是如何处理的
一般来说,滑动冲突本质是,滑动冲突是因为内外层组件在同一方向上都滑动,而系统并不能确定应该响应哪个组件的滑动事件,一般滑动冲突的场景有这几种
首先来看一个正常的例子,列表嵌套列表的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@Preview
@Composable
fun Sample1() {
LazyColumn(modifier = Modifier.height(200.dp)) {
items(20) {
Text(text = "列表项 $it")
}
item {
LazyColumn(modifier = Modifier.height(100.dp)) {
items(10) {
Text(text = "内部列表项 $it")
}
}
}
}
}
|
在这个列表中,可以发现,内部列表滑动到最顶部了,在往下拖拽,会拉着外层列表滑动,或者是滑动到最底部了,再往下拖拽同样也会拉着外层列表滑动,所以这个场景中,是没有滑动冲突的
而之所以这个场景没有滑动冲突的,是因为这里的内外层组件使用的都是官方的 LazyColumn
它的内部是对滑动冲突进行了处理的,包括 lazyRow
等等,接下来,我们将外层的 LazyColumn
替换成 Column
来看看
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
@Preview
@Composable
fun NestedScrollSample() {
/**
* 默认 Compose 的 lazyRow 和 lazyColum 是提供了嵌套滑动处理的
/
var offsetY by remember { mutableStateOf(0f) }
Column(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(0, offsetY.roundToInt()) }
.draggable(rememberDraggableState(onDelta = { delta ->
offsetY += delta // 拖拽滑动
}), orientation = Orientation.Vertical)
) {
(1..10).forEach {
Text(text = "第 $it 个")
}
// 处理嵌套滑动
LazyColumn(modifier = Modifier.height(60.dp)) {
items(10) {
Text(text = "第 $it 个项目")
}
}
}
}
|
这里的代码和上个例子基本类似,就是将 LazyColumn
换成 Column
了,然后自己定义了 offsetY
变量,这个变量用来做这个组件滑动的偏移,运行后是这样的
由于 Column
没有处理滑动冲突,所以不管内层列表滑到最顶部还是最底部,外层都无法拖动,接下来看看如何滑动冲突的,首先我们有几个前置知识必须了解
- 在
Compose
中处理事件和 Android
原生中有所不同,在 Android 原生中,所有的事件是从父组件开始响应的,由父组件来决定要不要拦截事件,是否分发事件给子组件
- 而在
Compose
中,事件首先由最深层的子组件接收(子组件会最先接受到事件),子组件开始响应的时候,先询问父组件是否需要先消费事件,然后父组件再根据需要去消费事件,然后消费事件后,再回到子组件自身进行消费,子组件消费完成后,如果自己还有没有消费完的,会再次询问父组件是否需要消费事件
所以它们充当着一种“发送者”和“接收者”的角色的一种模式, NestedScrollDispatcher
用于分发事件,而 NestedScrollConnection
用于接收和处理这些事件
Modifier.nestedScroll()
一共有两个参数可设置
1
2
3
4
|
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier = this then NestedScrollElement(connection, dispatcher)
|
NestedScrollConnection: 用于父组件处理来自子组件的滚动事件的机制。(通过实现 NestedScrollConnection 中的方法,会收到子组件发送过来的事件回调,父组件可以在滚动事件发生前后做出响应,比如调整自己的状态或者处理滚动偏移 )
NestedScrollDispatcher: 用于子组件向父组件分发滚动事件的场景。(当子组件在用户滑动时需要通知父组件,可以通过 NestedScrollDispatcher 来实现这种通信。这意味着,如果你的组件需要向上游组件报告滚动事件,你可以使用 dispatcher 参数。这个参数是可空的)
所以它们充当着一种“发送者”和“接收者”的角色的一种模式, NestedScrollDispatcher
用于分发事件,而 NestedScrollConnection
用于接收和处理这些事件
NestedScrollConnection 内部的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
interface NestedScrollConnection {
/**
* 子组件在事件响应之前触发的回调(也就是子组件在响应事件之前,会先询问父组件是否需要消费事件)
* @param available 当前可用的滑动事件偏移量
@param source 滑动事件的类型
*
* @return 当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
* 子组件处理后的滑动事件回调(也就是子组件在消费自己的事件后,会再次询问父组件是否需要消费事件)
* @param consumed 之前消费的所有滑动事件偏移量
* @param available 当前剩下还可用的滑动事件偏移量
* @param source 滑动事件的类型
* @return 当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
*/
fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = Offset.Zero
/**
* 触发快速滑动操作之前调用
* @param available 开始时的速度
* @return 当前组件消费的速度,如果不想消费可返回 Velocity.Zero
*/
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
/**
* 快速滑动操作结束后调用
* @param consumed 之前消费的所有速度
* @param available 当前剩下还可用的速度
* @return 当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理。
*/
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
|
有了这些前奏知识,也了解 nestedScroll 的相关属性和方法,接下来我们开始处理滑动冲突了
首先明白目标需求
- 在内层 LazyColumn 滑动到最顶部的时候,如果往下拉,需要滑动外层 Column 往下滑动
所以实现这个逻辑的步骤是
- 创建一个 NestedScrollConnection 实例,用于监听来自子组件 LazyColumn 的滑动事件回调
- 实现 onPostScroll 方法(监听子组件滑动后状态-因为这里的场景是 LazyColumn 滑动到顶部或者底部,让外层拖动)
- 在 NestedScrollConnection 中,根据 LazyColumn 的滑动位置决定是否消费滑动事件,如果 LazyColumn 已经滑动到最顶部或最底部,允许外层 Column 响应接下来的滑动事件,否则,LazyColumn 自身消费滑动事件
- 将 NestedScrollConnection 和外层 Column 的 Modifier 通过 nestedScroll 方法连接起来
这里顺带提一下为什么是要实现 onPostScroll 方法 ?
因为 onPostScroll 方法是监听子组件滑动后响应的回调,也就是监听 LazyColumn 滑动后的状态,然后决定是否需要让外层 Column 接管滑动。这样,当LazyColumn 滑动到顶部或底部时,如果用户继续滑动,外层 Column 可以接管滑动事件,从而解决滑动冲突,修改后的代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
@Preview
@Composable
fun NestedScrollSample() {
/**
* 默认 Compose 的 lazyRow 和 lazyColum 是提供了嵌套滑动处理的
*/
var offsetY by remember { mutableStateOf(0f) }
val connection = remember {
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
offsetY += available.y
return available
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(0, offsetY.roundToInt()) }
.nestedScroll(connection)
.draggable(rememberDraggableState(onDelta = { delta ->
offsetY += delta // 拖拽滑动
}), orientation = Orientation.Vertical)
) {
(1..10).forEach {
Text(text = "第 $it 个")
}
// 处理嵌套滑动
LazyColumn(modifier = Modifier.height(60.dp)) {
items(10) {
Text(text = "第 $it 个项目")
}
}
}
}
|
代码运行后,就是这个样子了,可以发现,LazyColumn 滑动顶部或者底部的时候,已经可以让外层的 Column 滑动了,已经解决滑动冲突了
接下来我们再看看另外一个例子,有点不一样的是,我们实现的是 onPreScroll 方法了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
@Preview
@Composable
fun NestedScrollSample3() {
val toolbarHeight = 68.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
// toolbar 的偏移量
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
// 监听子组件的滑动事件
val nestedScrollConnection = remember {
object : NestedScrollConnection {
// 列表在滑动之前回调
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// try to consume before LazyColumn to collapse toolbar if needed, hence pre-scroll
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
// here's the catch: let's pretend we consumed 0 in any case, since we want
// LazyColumn to scroll anyway for good UX
// We're basically watching scroll without taking it
return Offset.Zero // 这里返回的是父组件是否需要消费多少偏移量事件,这里我们默认返回 0,事件全给给子组件 LazyColumn,让它在任何情况下都能滚动
}
}
}
Box(
Modifier
.fillMaxSize()
// 当前组件作为父组件接受子组件的回调
.nestedScroll(nestedScrollConnection)
) {
// 内部组件 LazyColumn 已经对滑动冲突做了处理,所以它会回调在 nestedScrollConnection 中
LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) {
items(100) { index ->
Text(
"I'm item $index", modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
TopAppBar(
modifier = Modifier
.height(toolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) },
title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") }
)
}
}
|
代码注释都写的非常清楚了,大体上就是使用 nestedScrollConnection 类来监听子组件的回调,然后将回调的结果处理 TopAppBar 的隐藏和显示,这个例子唯一区别不同的是使用的是 onPreScroll 方法,因为子组件列表在滑动之前就需要接受回调,并且处理 TopAppBar 的显示和隐藏,代码运行后效果如下
所以一般情况下,NestedScrollConnection 是用来处理当前组件作为父组件接受子组件的回调,然后根据子组件滚动做出反应,例如,具有可滚动项的 LazyColumn 或 LazyRow 和可折叠标题的布局,或者上面的例子, 而 NestedScrollDispatcher 作为子组件,在事件响应前和响应后,通知给父组件的回调
上面说了这么多,那 NestedScrollDispatcher 又是怎么使用的呢 ?在这里我们回顾一下上面讲到的例子是都是使用的 NestedScrollConnection,而它的作用是作为父组件的时候,接受子组件的回调,这个接受子组件的组件回调,是因为我们目前子组件都是使用的 LazyColumn
而 LazyColumn 是内部使用 NestedScrollDispatcher 向我们发送了通知,所以 NestedScrollConnection 才能够接受的到,感兴趣的可以看下 LazyColumn 内部的源码,代码很多,这里不展示了,回到我们的第二个例子中,我们是没有处理 NestedScrollDispatcher 事件通知的,假设
我们将第二个例子外部再包裹一层可滑动的组件的话,那么仍然会有滑动冲突的问题,同时就算外部一样的逻辑编写 NestedScrollConnection 处理也是无效的,因为内部组件并没有分发事件,接下来,我们来处理一下这个场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
@Preview
@Composable
fun NestedScrollSample2() {
val connection = 和之前的一致
val dispatcher = remember { NestedScrollDispatcher() }
Column(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(0, offsetY.roundToInt()) }
.nestedScroll(connection, dispatcher)
.draggable(rememberDraggableState(onDelta = { delta ->
// 滑动前
val parentsConsumed = dispatcher.dispatchPreScroll(Offset(0f, delta), NestedScrollSource.Drag)
// 自己需要消费的
var weConsumed = delta - parentsConsumed.y
offsetY = weConsumed
// 剩余可用的偏移量
val adjustedAvailable = delta - parentsConsumed.y
// 一共消费了多少偏移量 (父组件偏移量 + 子组件的偏移量)
val totalConsumed = Offset(x = 0f, y = weConsumed) + parentsConsumed
// 最后还剩下多少偏移量
val left = adjustedAvailable - weConsumed
// 最后询问父组件还需要消费多少事件
dispatcher.dispatchPostScroll(totalConsumed, Offset(x = 0f, y = left),, NestedScrollSource.Drag)
}), orientation = Orientation.Vertical)
) {
(1..10).forEach {
Text(text = "第 $it 个")
}
// 处理嵌套滑动
LazyColumn(modifier = Modifier.height(60.dp)) {
items(10) {
Text(text = "第 $it 个项目")
}
}
}
}
|
整体代码就是这样,增加的逻辑不多,需要关注的就是下面这个部分,在子组件事件响应的前后通知父组件
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 滑动前-通知父组件
val parentsConsumed = dispatcher.dispatchPreScroll(Offset(0f, delta), NestedScrollSource.Drag)
// 自己需要消费的偏移量
var weConsumed = delta - parentsConsumed.y
offsetY = weConsumed
// 剩余可用的偏移量
val adjustedAvailable = delta - parentsConsumed.y
// 一共消费了多少偏移量 (父组件偏移量 + 子组件的偏移量)
val totalConsumed = Offset(x = 0f, y = weConsumed) + parentsConsumed
// 最后还剩下多少偏移量
val left = adjustedAvailable - weConsumed
// 最后通知父组件是否消费剩下的偏移量
dispatcher.dispatchPostScroll(totalConsumed, Offset(x = 0f, y = left),, NestedScrollSource.Drag)
|
到了这里,是不是就和我们上面讲到的逻辑是一样的,Compose 的事件分发的规则是:子组件开始响应的时候,先询问父组件是否需要先消费事件,然后父组件再根据需要去消费事件,然后消费事件后,再回到子组件自身进行消费,子组件消费完成后,如果自己还有没有消费完的,会再次询问父组件是否需要消费事件
总结
所以,一个组件的滑动冲突处理,必须要包含两个处理,那就是 NestedScrollDispatcher 子组件通知父组件事件偏移量,和 NestedScrollConnection 接受来自子组件的事件回调
引用链接:https://stackoverflow.com/questions/68145818/jetpack-compose-nestedscrollconnection-vs-nestedscrolldispatcher
https://developer.android.com/reference/kotlin/androidx/compose/ui/input/nestedscroll/package-summary.html#(androidx.compose.ui.Modifier).nestedScroll(androidx.compose.ui.input.nestedscroll.NestedScrollConnection,androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher)