前言
嵌套滚动是日常开发中常见的需求,能够在有限的屏幕中动态展示多样的内容。以淘宝搜索页为例,使用 Jetpack Compose 实现嵌套滚动。
|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| | |
NestedScrollConnection
Compose 中可以使用 nestedScroll 修饰符来自定义嵌套滚动的逻辑,其中 NestedScrollConnetcion 是连接组件与嵌套滚动体系的关键,它提供了四个回调函数,可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
kotlin
interface NestedScrollConnection {
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = Offset.Zero
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
onPreScroll
方法描述:预先劫持滑动事件,消费后再交由子布局。
参数列表:
- available:当前可用的滑动事件偏移量
- source:滑动事件的类型
返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero
onPostScroll
方法描述:获取子布局处理后的滑动事件
参数列表:
- consumed:之前消费的所有滑动事件偏移量
- available:当前剩下还可用的滑动事件偏移量
- source:滑动事件的类型
返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
onPreFling
方法描述:获取 Fling 开始时的速度。
参数列表:
- available:Fling 开始时的速度
返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero
onPostFling
方法描述:获取 Fling 结束时的速度信息。
参数列表:
- consumed:之前消费的所有速度
- available:当前剩下还可用的速度
返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理
实现嵌套滚动
示例分析
如截图所示的搜索页可以分为5个部分。搜索栏位置固定,不随滑动而改变。当手指向上滑动时,首先店铺卡片向上滑动,伴随透明度降低,接着tab栏和排序栏一起向上滑动,最后列表内的条目才会被向上滑动。当手指向下滑动,首先tab栏和排序栏向下滑动,接着列表内的条目向下滑动,最后店铺卡片才会出现。
设计实现方案
选择 LazyColumn 作为子布局实现商品列表,Tab栏、店铺卡片、筛选栏作为另外三个部分,放置在同一个父布局中统一管理。LazyColumn 已经支持嵌套滚动系统,能够将滑动事件传递给父布局,因此我们希望在子布局消费滑动事件的前、后,由父布局消费一部分滑动事件,从而改变Tab栏、店铺卡片、筛选栏的布局位置。
滑动事件 | 消费顺序 | 处理的位置 |
---|---|---|
手指上滑 available.y < 0 | 1. 店铺卡片上滑 | onPreScroll 拦截 |
手指上滑 available.y < 0 | 2. Tab栏、筛选栏上滑 | onPreScroll 拦截 |
手指上滑 available.y < 0 | 3. 列表上滑 | 子布局消费 |
手指下滑 available.y > 0 | 1. Tab栏、筛选栏下滑 | onPreScroll 拦截 |
手指下滑 available.y > 0 | 2. 列表下滑 | 子布局消费 |
手指下滑 available.y > 0 | 3. 店铺卡片下滑 | 自动分发到父布局 |
实现 SearchState 管理滚动状态
模仿 ScrollState,实现 SearchState 以管理父布局的滚动状态。value 代表当前滚动的位置,maxValue 代表父布局滚动的最大距离,从0到 maxValue 的范围又被商品卡片的高度 cardHeight 划分为两个阶段。定义 canScrollForward2 标记是否处在应该由Tab栏、筛选栏滑动的区间。
value | 消费滑动事件的控件 |
---|---|
0 <= value < cardHeight | 店铺卡片滑动 |
cardHeight <= value < maxValue | Tab栏、筛选栏滑动 |
value = maxValue | 商品列表滑动 |
kotlin
@Stable
class SearchState {
// 当前滚动的位置
var value: Int by mutableStateOf(0)
private set
var maxValue: Int
get() = _maxValueState.value
internal set(newMax) {
_maxValueState.value = newMax
if (value > newMax) {
value = newMax
}
}
var cardHeight: Int
get() = _cardHeightState.value
internal set(newHeight) {
_cardHeightState.value = newHeight
}
private var _maxValueState = mutableStateOf(Int.MAX_VALUE)
private var _cardHeightState = mutableStateOf(Int.MAX_VALUE)
private var accumulator: Float = 0f
// 同 ScrollState 实现,父布局不会消费超过 maxValue 的部分
val scrollableState = ScrollableState {
val absolute = (value + it + accumulator)
val newValue = absolute.coerceIn(0f, maxValue.toFloat())
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.roundToInt()
value += consumedInt
accumulator = consumed - consumedInt
// Avoid floating-point rounding error
if (changed) consumed else it
}
private fun consume(available: Offset): Offset {
val consumedY = -scrollableState.dispatchRawDelta(-available.y)
return available.copy(y = consumedY)
}
// 是否应该进行第二阶段滚动,改变Tab栏和搜索栏的偏移
val canScrollForward2 by derivedStateOf { value in cardHeight..maxValue }
}
@Composable
fun rememberSearchState(): SearchState {
return remember { SearchState() }
}
实现 NestedScrollConnection
根据上文所述,需要在 onPreScroll 回调函数在合适的时机拦截滑动事件,使得父布局在子布局之前消费滑动事件。
kotlin
internal val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 手指向上滑动时,直接拦截,由父布局消费,直到超过 maxValue,再由子布局消费
return if (available.y < 0) consume(available)
// 手指向下滑动时,在 cardHeight 到 maxValue 的区间内由父布局拦截,在子布局之前消费
else if (available.y > 0 && canScrollForward2) {
val deltaY = available.y.coerceAtMost((value - cardHeight).toFloat())
consume(available.copy(y = deltaY))
} else super.onPreScroll(available, source)
}
}
另外,为了操作体验的连续性,如果触摸了 LazyColumn 以外的区域,并且手指不离开屏幕持续向上滑动,在超出父布局能消费的范围后,我们希望能将剩余滑动事件再传递给子布局继续消费。为了实现这一功能,增加一个 NestedScrollConnection 对象,在 onPostScroll 回调中,将父布局消费后剩余的滑动事件传递到 LazyColumn 内部。这里处理了拖拽的情况,对于这种情况下 fling 速度的传递,也将在下文处理。
kotlin
@Composable
fun Search(modifier: Modifier = Modifier, state: SearchState = rememberSearchState()) {
val flingBehavior = ScrollableDefaults.flingBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val outerNestedScrollConnection = object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (available.y < 0) {
scope.launch {
// 由子布局 LazyColumn 继续消费剩余滑动距离
listState.scrollBy(-available.y)
}
return available
}
return super.onPostScroll(consumed, available, source)
}
}
Layout(...) {...}
}
实现父布局及其 MeasurePolicy
由于需要改变父布局中内容的放置位置,使用 Layout 作为父布局,其中前三个子布局使用 Text 控件标识,对店铺卡片设置动态透明度。
kotlin
Layout(
content = {
// TopBar()
Text(text = "TopBar")
// ShopCard()
Text(
text = "ShopCard",
// 背景和文字都随着滑动距离改变透明度
modifier = Modifier
.background(
alpha = 1 - state.value / state.maxValue.toFloat()
)
.alpha(1 - state.value / state.maxValue.toFloat())
)
// SortBar()
Text(text = "SortBar")
// CommodityList()
List(listState)
},
...
)
Layout 控件并不默认支持嵌套滚动,因此需要使用 scrollable 修饰符使其能够滚动并参与到嵌套滚动系统中。将 SearchState 中的 scrollableState 作为 state 入参,在 flingBehavior 入参中将父布局未消费完的 fling 速度,传递给子布局 LazyColumn 继续消费,使得操作体验连续。
前文实现了两个 NestedScrollConnection 对象,分别用于处理父布局和子布局消费前后的滑动事件,在 Layout 的 Modifier 对象中使用 nestedScroll 修饰符进行组装。由于 Modifier 链中后加入的节点能先被遍历到,SearchState 中的 nestedScrollConnection 更靠后被调用,因此能更先拦截到子布局的触摸事件;outerNestedScrollConnection 在 scrollable 修饰符前被调用,因此能拦截 scrollable 处理父布局的触摸事件。
kotlin
Layout(
...
modifier = modifier
.nestedScroll(outerNestedScrollConnection)
.scrollable(
state = state.scrollableState,
orientation = Orientation.Vertical,
reverseDirection = true,
flingBehavior = remember {
object : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
val remain = with(this) {
with(flingBehavior) {
performFling(initialVelocity)
}
}
// 父布局未消费完的速度,传递给子布局继续消费
if (remain > 0) {
listState.scroll {
performFling(remain)
}
return 0f
}
return remain
}
}
},
)
.nestedScroll(state.nestedScrollConnection)
)
实现 MeasurePolicy,根据 SearchState 中的 value 计算各个组件的放置位置,以实现组件被滑动的视觉效果。
kotlin
Layout(...) { measurables, constraints ->
check(constraints.hasBoundedHeight)
val height = constraints.maxHeight
val firstPlaceable = measurables[0].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
val secondPlaceable = measurables[1].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
val thirdPlaceable = measurables[2].measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
)
// LazyColumn 限制高度为父布局最大高度
val bottomPlaceable = measurables[3].measure(
constraints.copy(minHeight = height, maxHeight = height)
)
// 更新 maxValue 和 cardHeight
state.maxValue = secondPlaceable.height + firstPlaceable.height + thirdPlaceable.height
state.cardHeight = secondPlaceable.height
layout(constraints.maxWidth, constraints.maxHeight) {
secondPlaceable.placeRelative(0, firstPlaceable.height - state.value)
// TopBar 覆盖在 ShopCard 上面,所以后放置
firstPlaceable.placeRelative(
0,
// 搜索栏在 value 超过 cardHeight 后才会开始移动
secondPlaceable.height - state.value.coerceAtLeast(secondPlaceable.height)
)
thirdPlaceable.placeRelative(
0,
firstPlaceable.height + secondPlaceable.height - state.value
)
bottomPlaceable.placeRelative(
0,
firstPlaceable.height + secondPlaceable.height + thirdPlaceable.height - state.value
)
}
}
效果
动图展示了 scroll 和 fling 两种情况下的效果。淘宝还实现了搜索栏、Tab栏、店铺卡片的透明度变化,营造了更自然的视觉效果,这里不再展开实现,聚焦使用 Jetpack Compose 实现嵌套滚动的效果。
|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| | |