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

相关推荐
帅次1 天前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
IAM四十二3 天前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
Wgllss4 天前
那些大厂架构师是怎样封装网络请求的?
android·架构·android jetpack
x02421 天前
Android Room(SQLite) too many SQL variables异常
sqlite·安卓·android jetpack·1024程序员节
alexhilton24 天前
深入理解观察者模式
android·kotlin·android jetpack
Wgllss24 天前
花式高阶:插件化之Dex文件的高阶用法,极少人知道的秘密
android·性能优化·android jetpack
上官阳阳1 个月前
使用Compose创造有趣的动画:使用Compose共享元素
android·android jetpack
沐言人生1 个月前
Android10 Framework—Init进程-15.属性变化控制Service
android·android studio·android jetpack
IAM四十二1 个月前
Android Jetpack Core
android·android studio·android jetpack
王能1 个月前
Kotlin真·全平台——Kotlin Compose Multiplatform Mobile(kotlin跨平台方案、KMP、KMM)
android·ios·kotlin·web·android jetpack·kmp·kmm