前言

嵌套翻滚是日常开发中常见的需求,可以在有限的屏幕中动态展现多样的内容。以淘宝查找页为例,运用 Jetpack Compose 完成嵌套翻滚。

Jetpack Compose 实现仿淘宝嵌套滚动
Jetpack Compose 实现仿淘宝嵌套滚动

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栏和排序栏向下滑动,接着列表内的条目向下滑动,最终店肆卡片才会呈现。

Jetpack Compose 实现仿淘宝嵌套滚动

设计完成方案

选择 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 < maxValue Tab栏、挑选栏滑动
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 完成嵌套翻滚的作用。

Jetpack Compose 实现仿淘宝嵌套滚动
Jetpack Compose 实现仿淘宝嵌套滚动

示例源码

Search.kt