背景
此前在Jetpack Compose中实现一个首页嵌套滑动吸顶效果的需求,研究了很久,不像在原生上资料比较多,网上大把的方案,而在compose上即使你知道了有一个nestedScroll修饰符,但是查看官方文档你会得到以下的描述。
嵌套滚动互操作性(从Compose 1.2.0开始) 当您尝试在可滚动的组合中嵌套可滚动的View元素,或者反过来时,您可能会遇到问题。最明显的问题会在您滚动子元素并达到其开始或结束边界时发生,然后期望父元素接管滚动。然而,这种预期的行为可能不会发生,或者可能无法按预期工作。
这个问题是由于可滚动的组合内置的期望所导致的。可滚动的组合具有“默认嵌套滚动”规则,这意味着任何可滚动的容器都必须参与嵌套滚动链,既要作为父级通过NestedScrollConnection参与,也要作为子级通过NestedScrollDispatcher参与。当子级到达边界时,子级会驱动父级的嵌套滚动。例如,这个规则允许Compose Pager和Compose LazyRow很好地协同工作。但是,当使用ViewPager2或RecyclerView进行互操作滚动时,由于它们没有实现NestedScrollingParent3,因此从子级到父级的连续滚动是不可能的。
Ok可以提炼出以下两个要点:
1、默认的嵌套行为是子组件滚动到边界时再传给父组件
2、如果你想自定义规则,就要用到NestedScrollConnection这个东西
然后给了一个实现CollapsingToolbarLayout的案例。
// Sets up the nested scroll connection between the Box composable parent
//1 and the child AndroidView containing the RecyclerView
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Updates the toolbar offset based on the scroll to enable
// collapsible behaviour
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection) // 2
) {
TopAppBar(
modifier = Modifier
.height(ToolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
)
AndroidView(
{ context ->
LayoutInflater.from(context)
.inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
with(findViewById<RecyclerView>(R.id.main_list)) {
layoutManager = LinearLayoutManager(context, VERTICAL, false)
adapter = NestedScrollInteropAdapter()
}
}.also {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(it, true)
}
},
// ...
)
}
关键在于注释1处的NestedScrollConnection重写onPreScroll方法和注释2处的Box父容器使用nestedScroll修饰符,除此之外没有更多详细介绍,为什么是这样?得明白NestedScrollConnection的API。
它提供了四个回调函数:
onPreScroll
方法描述:预先劫持滑动事件,消费后再交由子布局。
参数列表:
available:当前可用的滑动事件偏移量 source:滑动事件的类型 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
onPostScroll
方法描述:经过onPreScroll然后子组件滑动后的回调
参数列表:
consumed:之前消费的所有滑动事件偏移量 available:当前剩下还可用的滑动事件偏移量 source:滑动事件的类型 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
onPreFling
方法描述:获取 Fling 开始时的速度。
参数列表:
available:Fling 开始时的速度 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero
onPostFling
方法描述:获取 Fling 结束时的速度信息。
参数列表:
consumed:之前消费的所有速度
available:当前剩下还可用的速度
返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理。
解决方案
了解了这四个回调函数的定义,再回到我们的嵌套滑动吸顶需求,核心处理逻辑就是:
在onPreScroll中处理向上滑动时,如果父组件还能滑动,则父组件消费available偏移量,返回值也返回消费的偏移量,反之返回Offset.Zero
val outerScrollState = rememberLazyListState()
val scope = rememberCoroutineScope { Dispatchers.Main }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
//预先劫持滑动事件,消费后再交由子布局
//返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
logDebug("testt", "onPreScroll.. y=${available.y}")
val delta = available.y
//向上滑动且父组件还能滑动
val consumed = if (delta < 0 && outerScrollState.canScrollForward) {
// 向上滚动时,优先滚动外部LazyColumn
//注意这里取反
scope.launch {
outerScrollState.scrollBy(-available.y)
logDebug("testt", "走了scrollBy")
}
available.y
} else {
// 向下滚动时,优先滚动内部LazyColumn,所以这里不消耗事件
0f
}
return Offset(0f, consumed)
}
}
}
//父组件
LazyColumn(
Modifier.fillMaxSize(),
state = outerScrollState
){
item {
// 父组件的其它item
}
item {
Box(Modifier.nestedScroll(nestedScrollConnection))(
//子组件
LazyColumn() {
item {
//子组件的item
}
}
)
}
}
注意:nestedScroll修饰符要放在子组件上一层的那个组件,如果你放在父组件上,你会发现当你滑动子组件以外的父组件区域时,父组件没反应,但是scrollBy函数确实走了,原因不得而知
总结
Jetpack compose中使用nestedScroll修饰符和NestedScrollConnection来影响子组件的滑动,想要实现吸顶效果只需在NestedScrollConnection的onPreScroll回调中判断父组件是否能滑动,能滑动则消耗对应的Offset且return。