Jetpack Compose 中的嵌套滑动和 nestedScroll

921 阅读5分钟

为何要自定义嵌套滑动?

嵌套滑动(Nested Scrolling)在传统 View 系统中就已经有了,只不过是以 Jetpack 库的方式提供的,如 NestedScrollViewRecyclerView。而一些原生的组件却不支持完善的嵌套滑动,如 ScrollViewListView

Jetpack Compose 在设计之初,就充分考虑了嵌套滑动的需求,所以它对于嵌套滑动的支持是很完善的。比如 Modifier.scrollable()LazyColumnLazyRow 等内置组件都支持嵌套滑动。

嵌套的 LazyColumn 示例:

@Composable
fun NestedLazyColumnSample() {

    Box(
        Modifier.fillMaxSize()
    ) {
        LazyColumn(
            modifier = Modifier
                .border(width = 1.dp, color = Color.DarkGray)
                .height(250.dp)
                .align(Alignment.Center)
        ) {
            item {
                LazyColumn(
                    modifier = Modifier
                        .border(width = 1.dp, color = Color.LightGray)
                        .height(100.dp)
                ) {
                    items(count = 5) { index ->
                        Text(
                            text = "Inner LazyColumn:Item ${index + 1}",
                            modifier = Modifier.padding(4.dp)
                        )
                    }
                }
            }
            items(count = 10) { index ->
                Text(
                    text = "Outer LazyColumn:Item ${index + 1}",
                    modifier = Modifier.padding(4.dp)
                )
            }

        }
    }
}

运行效果:

滑动嵌套的 LazyColumn

可以看到:当手指在内部的 LazyColumn 上滑动时,它会优先滑动;当滑动到内容的边界后,继续拖动会导致外部的 LazyColumn 滚动。

既然 Compose 提供的嵌套滑动已经很完善了,我们为什么还要学习自定义嵌套滑动呢?

因为嵌套滑动的场景不止有内部优先,滑完再滑外部这么一种,你再来看看下面这个例子:

带有可折叠顶部应用栏(Collapsing Toolbar)的界面

运行效果:

当手指在上滑图片列表的过程中,会先收起顶部栏,然后列表内容才开始滑动;下滑时,列表内容先滚动,到顶部无法再滚动时,顶部栏才开始展开。

对于这种复杂的交互逻辑,Compose 虽然也替我们实现了,但总有特殊需求是内置组件是无法满足的,这时,就需要我们自定义嵌套滑动来实现。

Compose 的嵌套滑动:Modifier.nestedScroll

嵌套滑动的流程

在说怎么实现之前,先说一下 Compose 中嵌套滑动的整体工作流程。

通常情况下(并不是所有情况下都是这样),触摸事件是由最内层的可滑动组件捕获、处理的,它的那些父级、“爷爷”级...组件并不直接处理这些触摸事件,而是通过接收来自子组件的通知,来间接处理。

具体流程就是:

子组件在处理滚动距离之前(预滚动阶段 Pre-scroll),会通知父组件:子组件的可用滚动距离,父组件可以消费一定的滑动距离,并将实际消费的滚动距离返回给子组件。

子组件在得到父组件实际消费的距离后,会处理自身的滚动。

子组件处理自身的滚动后(后滚动阶段 Post-scroll),会再次通知父组件:剩余的滚动距离,父组件可以处理这部分滚动距离。

当前这个过程是递归的。每一个父组件被子组件通知后,也会通知自己的父组件,从而形成一个自下而上(事件派发)和自上而下(消耗决策)的嵌套消耗链。

为什么要通知两次父组件(在处理自身滚动的前后)?

因为这样提高了灵活性,我们既可以实现父组件优先滑动,也可以实现子组件优先滑动的场景。

Modifier.nestedScroll

知道了整体的工作流程后,我们来看看具体的代码。Compose 中嵌套滑动使用的是 Modifier.nestedScroll() 修饰符函数,它有两个参数,分别是 connectiondispatcher

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null // 注意:dispatcher 是可选的
)

那这两个参数分别是什么意思呢?

我们先来思考一个问题,我们怎么让一个只具备滑动功能的组件,能够参与到上述的嵌套滑动工作流程中呢?

image.png

其实很简单,只要满足嵌套滑动流程中所要求的事。总结就是两点:

  1. 当前组件作为父组件,内部有子组件滑动时,需要响应和消费子组件传递过来的滑动事件

  2. 当前组件作为子组件,自身发生了滑动时,需要将自己的滑动事件分发给其父组件。

函数的参数,connection 对应第一点,dispatcher 对应第二点。我们先来看看作为子组件该怎么实现。

NestedScrollDispatcher:作为子组件分发事件

我们先写一个具有最基本的拖动功能的列表:

@Composable
fun NestedSlidingListSample() {
    
    var offsetY by remember { mutableFloatStateOf(0f) }

    Column(
        Modifier
            .offset { IntOffset(x = 0, y = offsetY.roundToInt()) }
            .draggable(
                state = rememberDraggableState { delta ->
                    offsetY += delta // 直接修改偏移量
                },
                orientation = Orientation.Vertical
            )
    ) {
        for (i in 1..10) {
            Text(text = "Text $i")
        }
    }

}

我们要在自己的滑动行为的前后,去通知父组件,具体来说就是在 offsetY += delta 这行代码的前后。

首先,创建一个 NestedScrollDispatcher 实例,然后调用它的 dispatchPreScrolldispatchPostScroll 方法,像这样:

@Composable
fun NestedSlidingListSample() {
    var offsetY by remember { mutableFloatStateOf(0f) }
    // 创建 NestedScrollDispatcher 实例
    val dispatcher = remember { NestedScrollDispatcher() }

    Column(
        Modifier
            .offset { IntOffset(x = 0, y = offsetY.roundToInt()) }
            .draggable(
                state = rememberDraggableState { delta ->
                    // 在子组件自己滚动前,通知父组件
                    dispatcher.dispatchPreScroll()

                    offsetY += delta
                    
                    // 在子组件自己滚动后,再次通知父组件
                    dispatcher.dispatchPostScroll()
                },
                orientation = Orientation.Vertical,
            )
    ) {
        for (i in 1..10) {
            Text(text = "Text $i")
        }
    }

}

并且为了通知到正确父组件,并且知道当前分发事件的子组件是哪一个。

我们要填 Modifier.nestedScroll 函数的 dispatcher 参数:

@Composable
fun NestedSlidingListSample() {
    var offsetY by remember { mutableFloatStateOf(0f) }
    val dispatcher = remember { NestedScrollDispatcher() }

    Column(
        Modifier
            .offset { IntOffset(x = 0, y = offsetY.roundToInt()) }
            .draggable(
                state = rememberDraggableState { delta ->
                    dispatcher.dispatchPreScroll()
                    offsetY += delta
                    dispatcher.dispatchPostScroll()
                },
                orientation = Orientation.Vertical,
            )
            .nestedScroll(dispatcher = dispatcher) // 新增
    ) {
        for (i in 1..10) {
            Text(text = "Text $i")
        }
    }

}

然后先看看 onPreScroll(available: Offset, source: NestedScrollSource): Offset 函数,它在子组件滚动之前被调用,去通知父组件。

第一个 available 参数就是子组件当前可用的滚动距离;

第二个参数 source 是滚动来源。因为滚动事件可能来自用户的手指拖动,也可能是惯性滑动。具体有两个枚举值:NestedScrollSource.UserInput(用户手指拖动)、 NestedScrollSource.SideEffect(惯性滑动),因为有些场景不希望惯性滑动也能触发组件的滚动。

 dispatcher.dispatchPreScroll(
     available = Offset(x = 0f, y = delta), 
     source = NestedScrollSource.UserInput
 )

注意:dispatchPreScroll 函数有一个返回值,就是父组件实际消费掉的滚动距离。而这部分滚动距离,子组件就不能再使用了,所以子组件要从可用的总滚动距离中减去这个值,剩下的距离才是可滚动距离。

// 父组件实际消费的滚动距离
val consumed = dispatcher.dispatchPreScroll(
    available = Offset(x = 0f, y = delta),
    source = NestedScrollSource.UserInput
)

// 子组件消费剩下、可用的滚动距离
offsetY += (delta - consumed.y)

再来看看 onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset 函数,它会在子组件滚动之后被调用。

它有三个参数,比 onPreScroll 多了一个 consumed 参数,它表示子组件在处理自己的滚动时,实际消费的滚动距离。 available 表示子组件滑动后,剩余、可以给父组件的滚动距离;source 是滚动来源。

onPostScroll 也有返回值,不过不重要了,其实你也知道返回值的意思,实际消耗的距离呗。

这么填就可以了:

dispatcher.dispatchPostScroll( 
    consumed = Offset( x = 0f, y = (delta - consumed.y) ), 
    available = Offset.Zero, 
    source = NestedScrollSource.UserInput 
)

到此为止,第一件事就完成了,就是作为子组件,在滚动前、后分发事件给父组件。

另外,还有dispatchPreFling(available: Velocity): VelocitydispatchPostFling(consumed: Velocity, available: Velocity): Velocity 方法用于处理惯性滑动事件,原理和上述是一样的,只不过处理的是速度。

NestedScrollConnection:作为父组件响应事件

接下来,完成第二件事:作为父组件,响应并处理子组件的嵌套滑动通知。

首先创建 NestedScrollConnection 实例,发现它是一个接口,所以我们要实现它。

val connection = remember {
    object : NestedScrollConnection {
        // 处理 Pre-scroll (子组件滑动前)
        // 直接调用父的实现,父不优先消耗
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            return super.onPreScroll(available, source)
        }

        // 处理 Post-scroll (子组件滑动后)
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            // 父组件消费最后的滚动距离
            offsetY += available.y
            return Offset(x = 0f, available.y) // 返回实际消耗的量
        }
    }
}

最后将 NestedScrollConnection 实例填入到 nestedScroll 修饰符函数中的 connection 参数就行了。

来测试一下,嵌套一个 LazyColumnColumn 的内部,完整代码:

@Composable
fun NestedSlidingListSample() {

    var offsetY by remember { mutableFloatStateOf(0f) }

    val dispatcher = remember { NestedScrollDispatcher() } // 定义子组件分发滑动事件的行为

    // 定义父组件响应并处理滑动事件的行为
    val connection = remember {
        object : NestedScrollConnection {
            // 子组件处理滑动之前,不消费任何位移,也就是子组件优先滚动
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                return super.onPreScroll(available, source)
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                // 子组件处理滑动之后,消费掉剩余的垂直位移
                offsetY += available.y
                return Offset(x = 0f, available.y) // 返回直接消费的量
            }
        }
    }
    Column(
        Modifier
            .offset { IntOffset(x = 0, y = offsetY.roundToInt()) }
            .draggable(
                state = rememberDraggableState { delta ->
                    // 父组件实际消费的滚动距离
                    val consumed = dispatcher.dispatchPreScroll(
                        available = Offset(x = 0f, y = delta),
                        source = NestedScrollSource.UserInput
                    )

                    // 子组件消费掉当前可用的滚动距离
                    offsetY += (delta - consumed.y)

                    // 通知父组件,子组件实际消费的距离
                    dispatcher.dispatchPostScroll(
                        consumed = Offset(
                            x = 0f,
                            y = (delta - consumed.y)
                        ), available = Offset.Zero, source = NestedScrollSource.UserInput
                    )
                },
                orientation = Orientation.Vertical,
            )
            .nestedScroll(dispatcher = dispatcher, connection = connection) // 自定义嵌套滑动
    ) {
        for (i in 1..10) {
            Text(text = "Text $i")
        }

        // 嵌套滑动列表测试
        LazyColumn(Modifier.height(100.dp)) {
            items(count = 15){
                Text("Lazy Text $it")
            }
        }
    }

}

运行结果:

自定义的嵌套滑动组件

可以看到在内部的 LazyColumn 滑动到内容的边界后,用户继续滑动,LazyColumn 会通过其 NestedScrollDispatcher 实例调用 dispatchPostScroll 函数。此时,外部的 ColumnNestedScrollConnection 实例中的 onPostScroll 回调就会被触发,导致使得外部的 Column 能够消费到剩余的滑动距离,从而被拖着一起滚动了。

怎么样?是不是豁然开朗。

dispatcher 为何为可选?

有些人可能注意到了,NestedScrollConnection 是一个接口,而 NestedScrollDispatcher 却是一个类,并且 Modifier.nestedScrollconnection 参数是必需的,dispatcher 参数却是可选的(可为null)。

如果 dispatcher 参数为 null,这意味着这个组件在自身开始滚动时,并不会将滑动事件发放给父组件。

这是为什么?

这种情况适用于那些自身不可直接通过触摸滑动,但需要协调其子组件滑动的容器。

带有可折叠顶部应用栏(Collapsing Toolbar)的界面

回顾开头那个可折叠顶部应用栏的例子,你是无法直接拖动顶部栏的,你只有通过滑动图片列表来间接地展开/收起顶部栏,我们是有这种需求的。

这就是dispatcher 参数可以为空的典型原因。