所谓嵌套滚动,就是两个组件之间出现滚动事件冲突了,要给与特定的处理逻辑。在传统 View 系统中称之为滑动冲突,一般有两种解决方案,外部拦截法和内部拦截法。在 Jetpack Compose 中,提供了 Modifier.nestedScroll
修饰符用来处理嵌套滚动的场景。
在介绍 Modifier.nestedScroll
之前,需要先了解 Compose 中嵌套滚动的处理思想。当组件获得滚动事件后,先交给它的父组件消费,父组件消费之后,将剩余可用的滚动事件在给到子组件,子组件再消费,子组件消费之后,再将剩余的滚动事件再给到父组件。
第一趟 ... -> 孙 ——> 子 ——> 父 -> ...
第二趟 ...
第三趟 ... -> 孙 ——> 子 ——> 父 -> ...
有了整体思路之后,再来看 Modifier.nestedScroll 这个修饰符。
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier
使用 nestedScroll 参数列表中有一个必选参数 connection
和一个可选参数 dispatcher
connection: 嵌套滑动手势处理的核心逻辑,内部回调可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
dispatcher:调度器,内部包含用于父布局的 NestedScrollConnection , 可以调用 dispatch* 方法来通知父布局发生滑动
NestedScrollConnection
提供了四个回调方法。
interface NestedScrollConnection {
/**
* 预先劫持滑动事件,消费后再交由子布局。
* available:当前可用的滑动事件偏移量
* source:滑动事件的类型
* 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
* 获取子布局处理后的滑动事件
* consumed:之前消费的所有滑动事件偏移量
* available:当前剩下还可用的滑动事件偏移量
* source:滑动事件的类型
* 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
*/
fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = Offset.Zero
/**
* 获取 Fling 开始时的速度
* available:Fling 开始时的速度
* 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero
*/
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
/**
* 获取 Fling 结束时的速度信息
* consumed:之前消费的所有速度
* available:当前剩下还可用的速度
* 返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理
*/
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity.Zero
}
}
关于各个方法的含义已经在方法注释中标注了。
注意 Fling 的含义: 当我们手指在滑动列表时,如果是快速滑动并抬起,则列表会根据惯性继续飘一段距离后停下,这个行为就是 Fling ,onPreFling 在你手指刚抬起时便会回调,而 onPostFling 会在飘一段距离停下后回调。
NestedScrollDispatcher 的主要方法:
fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
return parent?.onPreScroll(available, source) ?: Offset.Zero
}
fun dispatchPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
}
suspend fun dispatchPreFling(available: Velocity): Velocity {
return parent?.onPreFling(available) ?: Velocity.Zero
}
suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
return parent?.onPostFling(consumed, available) ?: Velocity.Zero
}
其实方法实现就能清楚,实际上让其父组件调用 NestedScrollConnection
中的预消费与后消费方法。
先上效果图:
简单分析一下效果:
下面开始编码:
@Composable
fun NestedScrollDemo() {
Column(
modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.mipmap.rc_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
LazyColumn {
repeat(50) {
item {
Text(text = "item --> $it", modifier = Modifier.fillMaxWidth())
}
}
}
}
}
@Composable
fun NestedScrollDemo() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) {
minHeight.toPx()
}
val maxHeightPx = with(density) {
maxHeight.toPx()
}
var topHeightPx by remember {
mutableStateOf(maxHeightPx)
}
val connection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return super.onPreScroll(available, source)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
return super.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return super.onPostFling(consumed, available)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(connection = connection)
) {
Image(
painter = painterResource(id = R.mipmap.rc_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) {
topHeightPx.toDp()
})
)
LazyColumn {
repeat(50) {
item {
Text(text = "item --> $it", modifier = Modifier.fillMaxWidth())
}
}
}
}
}
onPreScroll
中实现,然后把剩余的给到 LazyColumn,最后 LazyColumn 消费后还有剩余的滑动距离,Column 处理 —— 在 onPostScroll
中处理。val connection = remember {
object : NestedScrollConnection {
/**
* 预先劫持滑动事件,消费后再交由子布局。
* available:当前可用的滑动事件偏移量
* source:滑动事件的类型
* 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
*/
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Drag) { // 判断是滑动事件
if (available.y dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
topHeightPx += available.y
return Offset(x = 0f, y = available.y)
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离。剩余的给到子组件。
topHeightPx += dH
return Offset(x = 0f, y = dH)
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (available.y
所有的代码通过注释已经写的很详细了。这样就实现了上图的效果。
滑动距离消费不一定要体现在位置、大小之类的变化上。当使用 Modifier.nestedScroll 修饰符处理嵌套滚动时,绝大多数场景使用外部拦截法就能轻松实现,给父容器修饰,实现 NestedScollConnection 方法。
使用内部拦截法,一般用于父组件也可以消费事件,需要子容器使用 Modifier.nestedScroll ,并合理使用 NestedScrollDispatcher 的方法。
看下面这个示例
同样简单分析一下效果:
实现如下:
@Composable
fun NestedScrollDemo4() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) {
minHeight.toPx()
}
val maxHeightPx = with(density) {
maxHeight.toPx()
}
var topHeightPx by remember {
mutableStateOf(maxHeightPx)
}
val connection = remember {
object : NestedScrollConnection {}
}
val dispatcher = NestedScrollDispatcher()
LazyColumn(
modifier = Modifier
.background(Color.LightGray)
.fillMaxSize()
) {
for (i in 0..10) {
item {
Text(text = "item --> $i", modifier = Modifier.fillMaxWidth())
}
}
item {
Image(
painter = painterResource(id = R.mipmap.rc_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) {
topHeightPx.toDp()
})
.draggable(
state = rememberDraggableState { onDelta ->
// 1. 滑动距离,给到父组件先消费
// 调用父组件劫持滑动事件,让父组件先消费,返回值是父组件消费掉的滑动距离
// 这里并不想让父组件先消费,就给父组件传了 Offset.Zero。 返回值也就是 Offset.Zero。
val consumed = dispatcher.dispatchPreScroll(
available = Offset(x = 0f, y = 0f), source = NestedScrollSource.Drag
)
// 2. 父组件消费完之后,剩余的滑动距离,自己按需消费
// 计算父组件消费后剩余的可使用的滑动距离
val availableY = onDelta - consumed.y
// canConsumeY 是当前需要消费掉的距离
val canConsumeY = if (availableY dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
availableY
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离
dH
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (availableY $j", modifier = Modifier.fillMaxWidth())
}
}
}
}
关键代码都已经加上了注释。看起来应该是非常清晰的。
这里,主要是内部使用 dispatcher 进行事件拦截。
在 3.1 的例子中,头部图片是不检测滑动事件的,手指按住图片滑动是不会响应的,现在需要修改为按住上面图片也是可以滑动,将头部收缩和展开。
下面开始改造:
Column(
modifier = Modifier
.fillMaxSize()
.draggable(
state = rememberDraggableState { onDelta ->
},
orientation = Orientation.Vertical
)
.nestedScroll(connection = connection)
) {
...
}
val dispatcher = remember { NestedScrollDispatcher() }
Column(
modifier = Modifier
.fillMaxSize()
.draggable(
state = rememberDraggableState { onDelta ->
// 1. 滑动距离,给到父组件先消费
// 调用父组件劫持滑动事件,让父组件先消费,返回值是父组件消费掉的滑动距离
val consumed = dispatcher.dispatchPreScroll(
available = Offset(x = 0f, y = onDelta), source = NestedScrollSource.Drag
)
// 2. 父组件消费完之后,剩余的滑动距离,自己按需消费
// 计算父组件消费后剩余的可使用的滑动距离
val availableY = (onDelta - consumed.y)
// consume 是当前需要消费掉的距离
val consumeY = if (availableY dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
availableY
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离
dH
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (availableY
同样代码注释已经写得非常清晰了。
完整代码如下:
@Composable
fun NestedScrollDemo2() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) {
minHeight.toPx()
}
val maxHeightPx = with(density) {
maxHeight.toPx()
}
var topHeightPx by remember {
mutableStateOf(maxHeightPx)
}
val dispatcher = remember { NestedScrollDispatcher() }
val connection = remember {
object : NestedScrollConnection {
/**
* 预先劫持滑动事件,消费后再交由子布局。
* available:当前可用的滑动事件偏移量
* source:滑动事件的类型
* 返回值:当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
*/
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Drag) { // 判断是滑动事件
if (available.y dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
topHeightPx += available.y
return Offset(x = 0f, y = available.y)
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离。剩余的给到子组件。
topHeightPx += dH
return Offset(x = 0f, y = dH)
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (available.y
// 1. 滑动距离,给到父组件先消费
// 调用父组件劫持滑动事件,让父组件先消费,返回值是父组件消费掉的滑动距离
val consumed = dispatcher.dispatchPreScroll(
available = Offset(x = 0f, y = onDelta), source = NestedScrollSource.Drag
)
// 2. 父组件消费完之后,剩余的滑动距离,自己按需消费
// 计算父组件消费后剩余的可使用的滑动距离
val availableY = (onDelta - consumed.y)
// consume 是当前需要消费掉的距离
val consumeY = if (availableY dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
availableY
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离
dH
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (availableY $it", modifier = Modifier.fillMaxWidth())
}
}
}
}
}
运行效果:
看效果图不明显,实际上就是按住图片位置拖动是可以收缩和展开顶部图片的。
当然,其实要实现这个效果,也不用整这么复杂,完全可以给 Image 设置 draggable 修饰符来实现:
Image(
painter = painterResource(id = R.mipmap.rc_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) {
topHeightPx.toDp()
})
.draggable(
state = rememberDraggableState { onDelta ->
val consumeY = if (onDelta dH) { // 如果当前可用的滑动距离全部消费都不足以达到最小高度,就将当前可用距离全部消费掉
onDelta
} else { // 如果当前可用的滑动距离足够达到最小高度,就只消费掉需要的距离
dH
}
} else { // 下滑
val dH = maxHeightPx - topHeightPx // 向下滑动过程中,还差多少达到最大高度
if (onDelta
这样就可以了。
NestedScrollConnection
中的 onPreScroll
和 onPostScroll
方法。当自己检测滚动事件,则需要使用 NestedScrollDispatcher
的相关方法对滚动事件进行分发。ScrollableDefaults.flingBehavior()
,感兴趣的朋友可以继续研究。
参与评论
手机查看
返回顶部