前语
上期传送门:Jetpack Compose 完结iOS的回弹作用究竟有多简略?跟着我,不难!(上)
下一期也写完了,传送门:Compose 用 nestedScroll 完结iOS的回弹作用,还要帮谷歌修bug?(完结)
什么是嵌套翻滚(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 期啊!
这么多字和这篇文章的润饰,足足费了我一整天啊!
点赞啊给我!保藏啊给我!不然我……
下期传送门:Compose 用 nestedScroll 完结iOS的回弹作用,还要帮谷歌修bug?(完结)