为何要自定义嵌套滑动?
嵌套滑动(Nested Scrolling)在传统 View 系统中就已经有了,只不过是以 Jetpack 库的方式提供的,如 NestedScrollView、RecyclerView。而一些原生的组件却不支持完善的嵌套滑动,如 ScrollView、ListView。
Jetpack Compose 在设计之初,就充分考虑了嵌套滑动的需求,所以它对于嵌套滑动的支持是很完善的。比如 Modifier.scrollable()、LazyColumn、LazyRow 等内置组件都支持嵌套滑动。
嵌套的 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 滚动。
既然 Compose 提供的嵌套滑动已经很完善了,我们为什么还要学习自定义嵌套滑动呢?
因为嵌套滑动的场景不止有内部优先,滑完再滑外部这么一种,你再来看看下面这个例子:
运行效果:
当手指在上滑图片列表的过程中,会先收起顶部栏,然后列表内容才开始滑动;下滑时,列表内容先滚动,到顶部无法再滚动时,顶部栏才开始展开。
对于这种复杂的交互逻辑,Compose 虽然也替我们实现了,但总有特殊需求是内置组件是无法满足的,这时,就需要我们自定义嵌套滑动来实现。
Compose 的嵌套滑动:Modifier.nestedScroll
嵌套滑动的流程
在说怎么实现之前,先说一下 Compose 中嵌套滑动的整体工作流程。
通常情况下(并不是所有情况下都是这样),触摸事件是由最内层的可滑动组件捕获、处理的,它的那些父级、“爷爷”级...组件并不直接处理这些触摸事件,而是通过接收来自子组件的通知,来间接处理。
具体流程就是:
子组件在处理滚动距离之前(预滚动阶段 Pre-scroll),会通知父组件:子组件的可用滚动距离,父组件可以消费一定的滑动距离,并将实际消费的滚动距离返回给子组件。
子组件在得到父组件实际消费的距离后,会处理自身的滚动。
子组件处理自身的滚动后(后滚动阶段 Post-scroll),会再次通知父组件:剩余的滚动距离,父组件可以处理这部分滚动距离。
当前这个过程是递归的。每一个父组件被子组件通知后,也会通知自己的父组件,从而形成一个自下而上(事件派发)和自上而下(消耗决策)的嵌套消耗链。
为什么要通知两次父组件(在处理自身滚动的前后)?
因为这样提高了灵活性,我们既可以实现父组件优先滑动,也可以实现子组件优先滑动的场景。
Modifier.nestedScroll
知道了整体的工作流程后,我们来看看具体的代码。Compose 中嵌套滑动使用的是 Modifier.nestedScroll() 修饰符函数,它有两个参数,分别是 connection 和 dispatcher。
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null // 注意:dispatcher 是可选的
)
那这两个参数分别是什么意思呢?
我们先来思考一个问题,我们怎么让一个只具备滑动功能的组件,能够参与到上述的嵌套滑动工作流程中呢?
其实很简单,只要满足嵌套滑动流程中所要求的事。总结就是两点:
-
当前组件作为父组件,内部有子组件滑动时,需要响应和消费子组件传递过来的滑动事件
-
当前组件作为子组件,自身发生了滑动时,需要将自己的滑动事件分发给其父组件。
函数的参数,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 实例,然后调用它的 dispatchPreScroll 和 dispatchPostScroll 方法,像这样:
@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): Velocity 和 dispatchPostFling(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 参数就行了。
来测试一下,嵌套一个 LazyColumn 在 Column 的内部,完整代码:
@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 函数。此时,外部的 Column 的 NestedScrollConnection 实例中的 onPostScroll 回调就会被触发,导致使得外部的 Column 能够消费到剩余的滑动距离,从而被拖着一起滚动了。
怎么样?是不是豁然开朗。
dispatcher 为何为可选?
有些人可能注意到了,NestedScrollConnection 是一个接口,而 NestedScrollDispatcher 却是一个类,并且 Modifier.nestedScroll 的 connection 参数是必需的,dispatcher 参数却是可选的(可为null)。
如果 dispatcher 参数为 null,这意味着这个组件在自身开始滚动时,并不会将滑动事件发放给父组件。
这是为什么?
这种情况适用于那些自身不可直接通过触摸滑动,但需要协调其子组件滑动的容器。
回顾开头那个可折叠顶部应用栏的例子,你是无法直接拖动顶部栏的,你只有通过滑动图片列表来间接地展开/收起顶部栏,我们是有这种需求的。
这就是dispatcher 参数可以为空的典型原因。