Compose 中, 处理滑动冲突的 ApiModifier.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 用于接收和处理这些事件

nestedScroll

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

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 的相关属性和方法,接下来我们开始处理滑动冲突了

首先明白目标需求

  1. 在内层 LazyColumn 滑动到最顶部的时候,如果往下拉,需要滑动外层 Column 往下滑动

所以实现这个逻辑的步骤是

  1. 创建一个 NestedScrollConnection 实例,用于监听来自子组件 LazyColumn 的滑动事件回调
    • 实现 onPostScroll 方法(监听子组件滑动后状态-因为这里的场景是 LazyColumn 滑动到顶部或者底部,让外层拖动)
  2. 在 NestedScrollConnection 中,根据 LazyColumn 的滑动位置决定是否消费滑动事件,如果 LazyColumn 已经滑动到最顶部或最底部,允许外层 Column 响应接下来的滑动事件,否则,LazyColumn 自身消费滑动事件
  3. 将 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

上面说了这么多,那 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)