注册

Jetpack Compose 实现仿淘宝嵌套滚动

前言


嵌套滚动是日常开发中常见的需求,能够在有限的屏幕中动态展示多样的内容。以淘宝搜索页为例,使用 Jetpack Compose 实现嵌套滚动。


74adb9fb47367098c25fa404c174e434.jpg
116114c2015f703a86b777ed90f4ab2d.jpg

NestedScrollConnection


Compose 中可以使用 nestedScroll 修饰符来自定义嵌套滚动的逻辑,其中 NestedScrollConnetcion 是连接组件与嵌套滚动体系的关键,它提供了四个回调函数,可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。


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栏和排序栏一起向上滑动,最后列表内的条目才会被向上滑动。
    • 当手指向下滑动,首先tab栏和排序栏向下滑动,接着列表内的条目向下滑动,最后店铺卡片才会出现。



b555f10f19701832ac7d520ac67df162.jpg

设计实现方案


选择 LazyColumn 作为子布局实现商品列表,Tab栏、店铺卡片、筛选栏作为另外三个部分,放置在同一个父布局中统一管理。LazyColumn 已经支持嵌套滚动系统,能够将滑动事件传递给父布局,因此我们希望在子布局消费滑动事件的前、后,由父布局消费一部分滑动事件,从而改变Tab栏、店铺卡片、筛选栏的布局位置。


滑动事件消费顺序处理的位置
手指上滑
available.y < 0
1. 店铺卡片上滑onPreScroll 拦截
2. Tab栏、筛选栏上滑
3. 列表上滑子布局消费
手指下滑
available.y > 0
1. Tab栏、筛选栏下滑onPreScroll 拦截
2. 列表下滑子布局消费
3. 店铺卡片下滑自动分发到父布局

实现 SearchState 管理滚动状态


模仿 ScrollState,实现 SearchState 以管理父布局的滚动状态。value 代表当前滚动的位置,maxValue 代表父布局滚动的最大距离,从0到 maxValue 的范围又被商品卡片的高度 cardHeight 划分为两个阶段。定义 canScrollForward2 标记是否处在应该由Tab栏、筛选栏滑动的区间。


value消费滑动事件的控件
0 <= value < cardHeight店铺卡片滑动
cardHeight <= value < maxValueTab栏、筛选栏滑动
value = maxValue商品列表滑动

@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 回调函数在合适的时机拦截滑动事件,使得父布局在子布局之前消费滑动事件。


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 速度的传递,也将在下文处理。


@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 控件标识,对店铺卡片设置动态透明度。


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 处理父布局的触摸事件。


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 计算各个组件的放置位置,以实现组件被滑动的视觉效果。


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 实现嵌套滚动的效果。


fefa0adf8ef17c51cff2a034660a0d53.jpg
cf1b1b2c8694a2edaeaebe0f38344906.jpg

示例源码


Search.kt


作者:Ovaltinez
来源:juejin.cn/post/7287773353309749303

0 个评论

要回复文章请先登录注册