前言
嵌套翻滚是日常开发中常见的需求,可以在有限的屏幕中动态展现多样的内容。以淘宝查找页为例,运用 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栏和排序栏向下滑动,接着列表内的条目向下滑动,最终店肆卡片才会呈现。
设计完成方案
选择 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 完成嵌套翻滚的作用。
示例源码
Search.kt