前言
看到京东的购物车页面在纵向滑动时有比较丰富的交互,包括嵌套滚动、粘性标题的效果,以及常见的下拉刷新功能,尝试用 Jetpack Compose 来实现一下。
实现嵌套滚动
首先梳理页面元素和在嵌套滚动中的表现:
- 当手指向上滑动时,顶部导航栏 逐渐隐藏,筛选栏 和商品列表也同步向上移动,直到导航栏完全消失后,商品列表内的条目向上移动
- 当手指向下滑动时,顶部导航栏 逐渐出现,筛选栏 和商品列表也同步向下移动,直到导航栏完全出现后,商品列表内的条目向下移动
总结来说,不论手指滑动方向,都是由父布局先消费滑动事件,累计消费超过导航栏的距离后,由子布局列表继续消费。
实现 CartState 和父布局 Layout
页面元素在嵌套滚动中的消费顺序比淘宝搜索页要简单不少。因此参考Jetpack Compose 实现仿淘宝嵌套滚动 ,进行一些 CV 和删改,实现 CartState 和父布局 Layout。CartState 中的关键部分是实现 NestedScrollConnection 对象,拦截子布局的消费事件。
kotlin
@Stable
class CartState {
// 相似部分省略
// 按前文分析,无论滑动方向,都由父布局先消费滑动事件,返回剩余未消费部分
internal val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return consume(available)
}
}
}
用 Text 控件标识顶部导航栏和筛选栏,使用 scrollable 修饰符使 Layout 控件具备滚动能力。
kotlin
@Composable
fun Cart(modifier: Modifier = Modifier, state: CartState = rememberCartState()) {
Layout(
content = {
// 顶部导航栏
Text("TopBar")
// 筛选栏
Text("SortBar")
// 商品列表,下文具体分析
CartList()
} ,
modifier = modifier
.scrollable(
state = state.scrollableState,
orientation = Orientation.Vertical,
// 为什么要设置反转方向,下文分析
reverseDirection = true,
)
.nestedScroll(state.nestedScrollConnection)
) { measurables, constraints ->
...
// 父布局能消费的最大滑动距离是顶部导航栏的高度
state.maxValue = firstPlaceable.height
...
}
}
reverseDirection 参数的设置
以下的源码分析基于版本 1.5.0。
这里打个岔分析一下 scrollable 修饰符中 reverseDirection 参数为什么要设置为 true 才会符合我们预期的效果。参数名显而易见是"反向"的意思,为什么需要取 true 让滚动方向反转呢?
在分析这个参数前,我们先把目光转向另一个 verticalScroll 修饰符,verticalScroll 是一个更为简单和高级的修饰符,能使它修饰的控件具备可滚动能力。在 verticalScroll 修饰符中有一个类似的参数 reverseScrolling,根据注释中的说明,在 reverseScrolling 为默认值 false 时,符合我们预期的效果,ScrollState.value 为0时页面在顶部。
verticalScroll 间接调用了 scrollable 修饰符,并且取反了 reverseScrolling 的值,赋值给 reverseDirection 参数。
再继续进入 ScrollableDefaults.reverseDirection 方法一探究竟。注释中说明,将 reverseScrolling(false) 取反并赋值给 reverseDirection(true) 后,屏幕中的内容会随手指而移动,而不是"视窗"跟随手指移动,于是我们能获得所谓"自然"的滑动效果。
现在我们明白了 reverseScrolling 取默认值 false,reverseDirection 取 true,能够获得符合预期的滑动效果。那为什么 reverseDirection 的默认值要设置为 false 呢?
因为 scrollable 的底层实现中考虑了兼容桌面端,同一套滚动逻辑也适用于鼠标滚轮。同样是页面从上往下滚动,在移动端和桌面端对应相反的滚动事件方向,移动端需要手指上移,在桌面端则是滚轮向下滚动。(MacOS 用户应该有所体会,触摸板的滚动方向与鼠标的滚动方向设置是联动的,当你取消勾选鼠标的"滚动方向:自然",将鼠标滚轮设置成符合操作习惯的反向,那么触摸板的滚动方向就会反直觉。)
scrollable 作为底层修饰符将 reverseDirection 的默认值设置为 false,在 verticalScroll 和 horizontalScroll 修饰符中则包含了将其默认设置为 true 的逻辑,以符合移动端的操作习惯。
说了这么多,日常移动端开发中遇到 verticalScroll 和 horizontalScroll 不能满足的情况,需要使用 scrollable 修饰符时,需要将 reverseDirection 参数设置为 true, 反转滑动方向,才能获得一般预期的效果。
实现粘性标题
在商品列表内部消费滑动事件的阶段,店铺名称是以粘性标题的形式,一段时间内固定在列表顶部。正好 Compose 提供了 stickyHeader 函数,于是愉快地把店铺标题元素使用 stickyHeader 函数添加到列表,其他元素用 item 函数添加,发现效果有一些奇怪:
我们希望实现的效果是,当卡片的底部形状靠近固定的标题时,标题和卡片底部一起向上移动,直到下一个标题到达顶部;实际的效果是,当下一个标题靠近上一个标题时,上一个标题才向上移动。仔细看下 stickyHeader 函数的注释,说明只有当下一个粘性标题到来,它都会一直固定在当前位置。
Adds a sticky header item, which will remain pinned even when scrolling after it. The header will remain pinned until the next header will take its place.
既然有这个特性,那我们不如把卡片底部、卡片间隔、卡片顶部组合起来,也作为粘性标题加入列表,让它们取代上一个标题的位置,上一个标题就能在卡片底部接近它时向上移动;接着下一个标题又无缝衔接它们,让它们继续向上移动,随后下一个标题固定在列表顶部。
kotlin
stickyHeader {
// 卡片底部
Spacer(
modifier = Modifier.background(Color.White, ShapeOfCardBottom)
)
// 卡片间隔
Spacer(
modifier = Modifier.height(10.dp)
)
if (index != 20) {
// 卡片顶部
Spacer(
modifier = Modifier.background(Color.White, ShapeOfCardTop)
)
}
}
实际上这样操作能实现预期的效果,但毕竟太不优雅了,所以接下来认真实现下。
首先忽略粘性标题,我们先实现一个列表,里面有若干分组(店铺),每个分组内有标题和若干条目(商品),分组包裹在卡片形状的布局内。
kotlin
@Composable
fun StickyCartList() {
val commodities = List(20) { it }
val groupedCommodities = remember(commodities) {
commodities.groupBy { it / 3 }
}
val startIndexes = remember(commodities) {
getStartIndexes(groupedCommodities.entries)
}
val endIndexes = remember(commodities) {
getEndIndexes(groupedCommodities.entries)
}
LazyColumn {
itemsIndexed(commodities) { index, commodity ->
// 如果是分组内的第一个商品,在它顶部添加间隔、卡片顶部和标题
// 因此如果列表内第一个可见的元素是它们,
// LazyListState.firstVisibleItemIndex 返回的是该商品对应的 index
if (startIndexes.contains(index)) {
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.background(Color.White, ShapeOfCardTop))
Text("Header${commodity / 3}")
}
// 普通商品
Text("Item$commodity")
// 如果是分组内的最后一个商品,在它底部添加卡片底部
// 因此如果列表内第一个可见的元素是卡片底部,
// LazyListState.firstVisibleItemIndex 返回的是该商品的 index
if (endIndexes.contains(index)) {
Spacer(modifier = Modifier.background(Color.White, ShapeOfCardBottom))
}
}
}
}
接下来要实现所谓粘性标题的效果。思路是在列表上方再覆盖一层标题,当列表中的标题上滑到列表顶部时,覆盖的标题出现在顶部并固定,营造"粘性"的效果;当卡片底部贴近标题后,这层覆盖的标题也随之向上移动,到完全不可见。实现的关键是计算覆盖的标题出现的时机 和移动的时机,显然需要拿到 LazyColumn 的 state 以进行计算。
kotlin
val listState = rememberLazyListState()
LazyColumn(state = listState)
接下来分析覆盖的标题出现的时机,只有一种情况不展示覆盖的标题:列表最顶部的元素是卡片间隔或卡片顶部,此时列表内的标题正在向上移动但还未贴到列表顶部。换个角度描述,firstVisibleItemIndex 是某个分组内的首个条目,而且该元素偏移的距离还没超过卡片间隔和卡片顶部的高度之和。
kotlin
val showHeader by remember {
derivedStateOf {
!(startIndexes.contains(listState.firstVisibleItemIndex)
&& listState.firstVisibleItemScrollOffset < topPadding)
}
}
分析覆盖的标题移动的时机:卡片底部形状已经贴到覆盖标题的底部,于是两者一起向上移动。换个角度描述,firstVisibleItemIndex 是某个分组内的末尾条目,而且该元素偏移的距离已经超过条目和标题的高度之差。
kotlin
val moveHeader by remember {
derivedStateOf {
endIndexes.contains(listState.firstVisibleItemIndex)
&& listState.firstVisibleItemScrollOffset > itemHeight - headerHeight
}
}
最后根据当前最顶部条目计算出覆盖的标题中应该显示的内容(这里只是示例,根据具体情况具体分析如何获取当前标题内容),终于可以把这个覆盖的标题写出来了。
kotlin
val firstVisibleItemIndex by remember {
derivedStateOf {
listState.firstVisibleItemIndex
}
}
if (showHeader) {
Text(
text = "Header${firstVisibleItemIndex / 3}",
modifier = Modifier.then(
if (moveHeader) Modifier.offset {
IntOffset(
0,
-(listState.firstVisibleItemScrollOffset - (itemHeight - headerHeight).toInt())
)
} else {
Modifier
}
)
)
}
至此成功实现了自定义粘性标题的效果。
实现下拉刷新
不重复造轮子,在商品列表外再包一层下拉刷新控件,用 delay 函数模拟网络请求,完成。
kotlin
@Composable
fun StickyCartListWithRefresh() {
val scope = rememberCoroutineScope()
val state = rememberPullToRefreshState(isRefreshing = false)
PullToRefresh(
state = state,
onRefresh = {
scope.launch {
state.isRefreshing = true
delay(1000)
state.isRefreshing = false
}
}
) {
StickyCartList()
}
}
效果
拆分开来各个功能不难实现,组合在一起后滑动的交互就很丰富。而且隐藏标题栏提升了屏效,粘性标题也避免在一家店铺下有很多商品时,滑动很多距离查看当前店铺。
源码在这里,欢迎star