Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)
前言
什么是嵌套滚动(nestedScroll)的理想状态?
我们到底想做成什么样子?不妨跟着我来看以下几个问题——
- 正确的、理想的情况应该是怎样的?
- 小米 MIUI 的行为在此场景下存在什么问题?
- oppo ColorOS 的行为在此场景下存在什么问题?
什么叫吊打Android、媲美iOS啊(后仰、吃薯片、笑)
理想状况
以下是咱们【越界回弹效果】面对【嵌套滚动场景】下的【理想状况】。
我们先逐步拆分一下最主要的几个场景。
情况1:child scroll -> parent overscroll
我们来看个图——
说明下图中的布局情况:
- parent 和 child 同时支持了嵌套滚动
- 且他们同时支持了我们终将实现的越界滚动效果
- parent 是 Column, child 是 LazyColumn
- parent 并不支持滑动。
- 在嵌套滚动发生时 它能【按需消费】滚动增量
让我们看看发生了什么——
- 当我下滑 child 时,child 已经 【划不动了】。
- 但是child 带动 parent 发生了 位移。
这里的 带动 就是我们所说的 【嵌套滚动】行为。
在这个过程中,原本发生在 child 中的手势,完全被 parent 接过去响应。
—— child 的事件被发送给 parent ,且 parent 把它们完全消费掉了!
情况2:child fling -> parent overscroll -> child fling
我们再来看另外一个情况,这次我做两个操作——
- 按住 child 往下拉
(和情况1同操作) - 上一步之后 不松手,紧接着 往上丢 (在 parent 越界滚动复位前松手)——
让我们看看 第2步 发生了什么——
- 因为 nestedScroll 的缘故,往上投掷 先交由 parent 过目
- parent 打眼一看:我正在 越界状态,于是它排了个计划表——
- 投掷(fling)有个 向上的速度(velocity),我用这个速度恢复我的越界状态先
- 越界状态恢复到 0 位(是零耶),但速度还有剩余,接下来我又有两个选择——
- 用这个速度继续越界滚动,让整体界面上移
- 问问 child ,这个速度 你要、还是不要?
- parent 具有朴素的社会主义价值观,他知道别人借自己用的东西,自己只用够用的就好了。多余的、还是得还回去,下次他才有的借。
- parent 消费(consume)掉了一部分速度(velocity)。
- parent 把剩下的速度还给了 child 。
child 拿着 parent 还回来的速度,发现自己可朝着速度的方向滑动。
- child 决定往那个方向溜达溜达。
情况3:child fling -> parent overscroll -> child fling -> parent overscroll
这一次我们做和上次 情况2 一样的操作。
但不同的是,我们 往上丢 的时候 大力点——
发生了和前面一样的情况 1 2 3 4 自不必说。
我们发现出现了新状况:
- child 滑动过程中,发现自己已经到边界了 ——没有更多内容需要显示了。
于是它问 parent :那你还要么。
parent : 义不容辞。
MIUI 存在什么问题?
为了防止辩解说 设置 app 是 ListView 做的,所以速度匹配不怎么好,咱们使用系统应用:文件管理做演示。
- 这总是 recyclerView 做的了吧。
- 当然,MIUI 上 ListView 和 RecyclerView 的行为几乎一致,这一点提出表扬。
值得指出的是,我都是用比较慢的速度进行一个【往回丢】,这样速度变化会看起来更明显。
看卡顿的 gif 图,结合我的描述,各位能知道个大概——
所以啥时候支持我上传视频?支持10M以下就够啊
速度问题在代码上出现得比较严重,但好在人工调参调得比较好,用户感知并不特别明显/能接受——
- gif 中,第一次往下丢的时候,边界处存在速度突增(设置app中表现更明显)。
- 大概是 500 -> 800 的这种感觉
第二次滑动是 上拉后直接松手,使得界面复位到最底部。
- 第三次滑动,我又做了个往下丢的手势(速度不高),可以看到 速度被吞了 ,直接比较生硬地进行了回弹。
欢迎用我 github 上的 demo 配合 MIUI 手机自己对比 :P
ColorOS 存在什么问题?
总结一句话:丢了。
- 在系统设置app中,【往回丢】这个动作的速度完全被吞,界面被弹簧以 0 的初速度拉回平衡点。
- 在这个 gif 展示的文件管理 app 中
- 绝对值较小的速度被吞。
- 高速会直接触发一个看起来是【固定速度的投掷】。
总之就是不跟手,越滑越难受、很难受。
看到这里,看官们对于我们的 期望效果 和 追求目标 应该心里有数了。
那么我们开始吧。
Modifier.nestedScroll
嵌套滚动的核心是 位移传递/消费 和 速度传递/消费。
实现 compose 版本前,不妨想想: View 中该怎么做
老规矩先拉踩一番 ——显然 View 的嵌套滚动是个很复杂的东西。
诸君,瞧瞧那 view 实现这个得写多少接口吧!
-
child
-
parent
得,看到这里、大家肯定是两手一摊:哦豁见鬼。
这还只是接口,咱们还得读一堆 API 说明吧?
读完说明,不了解的地方还得看看运行逻辑或者找找法子吧?
加上最终动画效果的实现……简直想想就绝望。
幸亏 google 爱世人,创造了 Compose 拯救我们于水火
不敢想不敢想…… 还是直接看 Compose 中的做法吧
- 就俩参数。
- 只需写一个该 Modifier ,你的组件就能够同时支持作为 child 和 parent!
看起来似乎一大串看不懂吼?
没事,我就放这,你跟完后面的内容,回头来看就能get到了——
这里说了一堆废话、不如直接去看接口注释
参数0:NestedScrollDispatcher
这个东西放后面讲就晚了。
来,看看码
总结放前面,方便你带着理解看代码:这个东西是用来通知 parent 干活的。
// 这里有个协程,注释告诉你说:用这个协程来执行fling动画!
val coroutineScope: CoroutineScope
// 这里有个parent……类型有点眼熟?
// ——巧了,咱们是不是一起作为 nestedScroll 参数来着?
// ——哦不好意思,您儿子和您长得真像!
internal val parent: NestedScrollConnection?
后面这些都一个道理:
都在调用 parent NestedScrollConnection
的对应function——
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
}
参数1:NestedScrollConnection
看我机翻一下 + 说点人话。
不用着急看下面的代码内容,过一遍接口,后面跟着我直接对着 API 理解就好——
@JvmDefaultWithCompatibility
interface NestedScrollConnection {
/**
* 由child调用,以允许parent预先使用拖动事件的一部分
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset
/**
* 当后代进行消费并通知祖先剩余的消费时,就会发生此传递
*/
fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset
/**
* 当child要进行fling时调用,让parent拦截并消耗部分初速
*/
suspend fun onPreFling(available: Velocity): Velocity
/**
* 当child完成投掷(并发送onPreScroll和onPostScroll事件)时 由child调用
*/
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity
}
一个个来理解吧。
假设我们是 布局树 中间 的一个组件哈 ——上有老、下有小的那种
onPreScroll
看注释:由child调用,以允许parent预先使用拖动事件的一部分。
什么意思呢?
- 意思是 child 会调用这个方法,问它的 parent :发工资了,你要是不要?
我明白了。所以,这新的一句话又是什么意思呢?
换句话说,onPreScroll
:
- 它被我们的 child 调用以通知我们做事。
- 我们可通过重写此函数,先一步消费掉一部分(或全部)事件。
onPostScroll
看注释:当后代进行消费并通知祖先剩余的消费时,就会发生此传递
什么意思呢?
- 意思是 child 会调用这个方法,问它的 parent :工资我花了 800 ,剩下 50 你要是不要?
换句话说:onPostScroll
:
- 他被我们的 child 调用,但发生在 child 进行消费之后
- 我们可重写此函数,按需消费剩余的部分(或全部)事件
onPreFling
好理解了,那就是 child 有了投掷速度,看我们要不要在 suspend 函数里消费一些嘛!
onPostFling
同理呗,child 实在花不完了,想起我们了。
怎么重写 NestedScrollConnection 中的 function?
回想一下我们的实现目标,Q&A 形式开始梳理逻辑。
根据我们的习惯,可以先写出一些前期准备代码了——
fun Modifier.overScrollOutOfBound(
// 还挺贪心,直接把两个方向都一次性解决
isVertical: Boolean = true,
nestedScrollToParent: Boolean = true,
scrollEasing: (currentOffset: Float, newOffset: Float) -> Float,
springStiff: Float = OutBoundSpringStiff,
springDamp: Float = OutBoundSpringDamp,
): Modifier = composed {
val hasChangedParams = remember(nestedScrollToParent, springStiff, springDamp, isVertical) { SystemClock.elapsedRealtimeNanos() }
val dispatcher = remember(hasChangedParams) { NestedScrollDispatcher() }
var offset by remember(hasChangedParams) { mutableFloatStateOf(0f) }
val nestedConnection = remember(hasChangedParams) {
object : NestedScrollConnection {
/**
* Spring 动画总有这么一个截止值,单位是像素
*/
val visibilityThreshold = 0.5f
/**
* 显然我们需要在拖拽时停止fling动画,且逻辑上它为空没意义,
* 可空还会导致我们取值时存在冗余的 ?: 条件
* 所以对我而言,这里只能是 lateinit 对象
*/
lateinit var lastFlingAnimator: Animatable<Float, AnimationVector1D>
// 这几个老熟人就不过多介绍了
override fun onPreScroll
override fun onPostScroll
override suspend fun onPreFling
override suspend fun onPostFling
}
}
this
.clipToBounds()
.nestedScroll(nestedConnection, dispatcher)
.graphicsLayer {
if (isVertical) translationY = offset else translationX = offset
}
}
onPreScroll
- 这个事件怎么产生的?
- 根据描述:Child 拿到事件的第一时间会给我们看看。
- 所以我们应该?
- 应该也第一时间给我们 parent 看看。
- 给 parent 是必须的吗?
- 肯定不必须啊,你得看产品需求怎么提,是吧?(取决于具体使用场景)
- ——但话又说回来了,对于我们当前要做的事情而言,是必须的。
fun onPreScroll(available) {
// 爹,你要不?
val parentConsumed = dispatcher.dispatchPreScroll()
// 爹用剩的,才是我可以用的
val realAvailable = available - parentConsumed
// ... 然后?
}
然后怎么做?继续——
- Child 正常滚动时,会问通过这function我们不?
- 根据描述:显然会,它次次都问。
- 这时候我们干预不?
- 不应干预,不然它还怎么滚。
- 怎么确定我们该不该干预?
- 啊?你问我?
- 那换个角度问,何时我们该在
onPreScroll
中干预?
- ……
你冥思苦想,发现有一种情况 你必须先消费事件:
- 我正在越界滚动状态。
这种情况下,直到归位前,我们一定要继续消费。
—— 综上,我们有 onPreScroll
代码如下——
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 停止 fling 动画
if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) {
// dispatcher 帮我们准备好了协程
dispatcher.coroutineScope.launch {
lastFlingAnimator.stop()
}
}
// 真正可用的是多少,要看问不问 parent
// 如果问,就要按需减去
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPreScroll(available, source)
else -> available
}
// 然后按需转换一下,得到【新增量】
val realOffset = if (isVertical) realAvailable.y else realAvailable.x
// 【新增量的方向】和【当前越界偏移方向】同向?
val isSameDirection = sign(realOffset) == sign(offset)
// 如果当前偏移小、或者新增量同向 ————意味着什么?
// 意味着我们还有机会在 onPostScroll 中继续处理它,不用太着急
if (abs(offset) <= visibilityThreshold || isSameDirection) {
// 这种情况就返回消耗量吧
return available - realAvailable
}
// offsetAtLast:理论最后的偏移量 = 当前已偏移+新增
// 但这个计算我们是通过一个阻尼函数实现的
// ——你也可以不用这个阻尼函数,无阻尼越界滚动
val offsetAtLast = scrollEasing(offset, realOffset)
// 如果,我是说如果,最后偏移量 和 当前偏移量 反向了
return if (sign(offset) != sign(offsetAtLast)) {
// 那就置0,消费的数量 = parent + offset + realOffset
offset = 0f
if (isVertical) {
Offset(x = available.x - realAvailable.x,
y = available.y - realAvailable.y + offset + realOffset)
} else {
Offset(x = available.x - realAvailable.x + offset + realOffset,
y = available.y - realAvailable.y)
}
} else {
offset = offsetAtLast
if (isVertical) {
Offset(x = available.x - realAvailable.x, y = available.y)
} else {
Offset(x = available.x, y = available.y - realAvailable.y)
}
}
}
onPostScroll
这里面我们将询问 parent 是否处理。
这事件是 child 传过来的。
——所以我们将消费全部事件。
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 我们需要提前询问,否则正在越界状态的parent会尬在原地
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPostScroll(consumed, available, source)
else -> available
}
// 其他没什么需要注意的对吧
offset = scrollEasing(offset, if (isVertical) realAvailable.y else realAvailable.x)
return if (isVertical) {
Offset(x = available.x - realAvailable.x, y = available.y)
} else {
Offset(x = available.x, y = available.y - realAvailable.y)
}
}
onPreFling
override suspend fun onPreFling(available: Velocity): Velocity {
// 不用解释吧?
if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) {
lastFlingAnimator.stop()
}
// 好理解了吧?
val parentConsumed = when {
nestedScrollToParent -> dispatcher.dispatchPreFling(available)
else -> Velocity.Zero
}
// 显而易见吧?
val realAvailable = available - parentConsumed
// 这很合理吧?
var leftVelocity = if (isVertical) realAvailable.y else realAvailable.x
// 如果我在越界状态 且 速度和越界方向相反
// 为什么要方向相反才处理?
// 因为如果方向相同,child 会在 onPostFling中把事件给我们
if (abs(offset) >= visibilityThreshold && sign(leftVelocity) != sign(offset)) {
lastFlingAnimator = Animatable(offset).apply {
// 更新边界,使得弹簧到0位就能直接结束当前回弹动作
// 从而把剩余的速度交给child继续处理
when {
leftVelocity < 0 -> updateBounds(lowerBound = 0f)
leftVelocity > 0 -> updateBounds(upperBound = 0f)
}
}
// 一目了然的写法,自己悟
leftVelocity = lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), leftVelocity) {
// 惯例,应用阻尼器
offset = scrollEasing(offset, value - offset)
}.endState.velocity
}
return if (isVertical) {
Velocity(parentConsumed.x, y = available.y - leftVelocity)
} else {
Velocity(available.x - leftVelocity, y = parentConsumed.y)
}
}
onPostFling
和 onPostScroll 类似。
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 理由同postScroll
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPostFling(consumed, available)
else -> available
}
// 和postScrol类似,是pre场景的简化
lastFlingAnimator = Animatable(offset)
lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), realAvailable.y) {
offset = scrollEasing(offset, value - offset)
}
return if (isVertical) {
Velocity(x = available.x - realAvailable.x, y = available.y)
} else {
Velocity(x = available.x, y = available.y - realAvailable.y)
}
}
结语
这样就算写完了嵌套滚动的核心部分了。
字数1w3,吐血两桶半。
果然还是得分 3 期啊!
这么多字和这篇文章的润色,足足费了我一整天啊!
点赞啊给我!收藏啊给我!不然我……
转载自:https://juejin.cn/post/7268553505705771062