Jetpack Compose 实现仿淘宝嵌套滚动

前言

嵌套滚动是日常开发中常见的需求,能够在有限的屏幕中动态展示多样的内容。以淘宝搜索页为例,使用 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 实现嵌套滚动的效果。

|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| | |

示例源码

Search.kt

相关推荐
沐言人生3 小时前
Android10 Framework—Init进程-9.服务端属性值初始化
android·android studio·android jetpack
Junerver7 天前
在 Jetpack Compose 中扩展 useRequest 实现自定义数据处理、异常回滚
android·前端·android jetpack
沐言人生8 天前
Android10 Framework—Init进程-5.SEAndroid机制
android·android studio·android jetpack
丶白泽13 天前
彻底掌握Android中的ViewModel
android·android jetpack
一杯凉白开14 天前
Now in Android !AndroidApp开发的最佳实践,让我看看是怎么个事?
android·架构·android jetpack
alexhilton14 天前
搞定在Jetpack Compose中优雅地申请运行时权限
android·kotlin·android jetpack
帅次22 天前
Android Studio:驱动高效开发的全方位智能平台
android·ide·flutter·kotlin·gradle·android studio·android jetpack
时空掠影2 个月前
Kotlin compose 实现Image 匀速旋转
android·java·开发语言·ios·kotlin·android jetpack·android-studio
白瑞德2 个月前
Android LiveData的使用和原理分析
android·android jetpack
alexhilton2 个月前
降Compose十八掌之『密云不雨』| Navigation
android·kotlin·android jetpack