前语

上期传送门:Jetpack Compose 完结iOS的回弹作用究竟有多简略?跟着我,不难!(上)

下一期也写完了,传送门:Compose 用 nestedScroll 完结iOS的回弹作用,还要帮谷歌修bug?(完结)

什么是嵌套翻滚(nestedScroll)的抱负状况?

咱们究竟想做成什么样子?无妨跟着我来看以下几个问题——

  • 正确的、抱负的状况应该是怎样的?
  • 小米 MIUI 的行为在此场景下存在什么问题?
  • oppo ColorOS 的行为在此场景下存在什么问题?

什么叫吊打Android、媲美iOS啊(后仰、吃薯片、笑)

抱负状况

以下是咱们【越界回弹作用】面对【嵌套翻滚场景】下的【抱负状况】。

咱们先逐渐拆分一下最主要的几个场景。

状况1:child scroll -> parent overscroll

咱们来看个图——

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)
说明下图中的布局状况:

  • parentchild 同时支撑了嵌套翻滚
    • 且他们同时支撑了咱们终将完结的越界翻滚作用
  • parent 是 Column, child 是 LazyColumn
  • parent 并不支撑滑动。
    • 在嵌套翻滚产生时 它能【按需消费】翻滚增量

让咱们看看产生了什么——

  1. 当我下滑 child 时,child 现已 【划不动了】。
  2. 但是child 带动 parent 产生了 位移

这儿的 带动 便是咱们所说的 【嵌套翻滚】行为。

在这个过程中,原本产生在 child 中的手势,彻底被 parent 接曩昔响应。

—— child 的事情被发送给 parent ,且 parent 把它们彻底消费掉了!

状况2:child fling -> parent overscroll -> child fling

咱们再来看别的一个状况,这次我做两个操作——

  1. 按住 child 往下拉 (和状况1同操作)
  2. 上一步之后 不松手,紧接着 往上丢 (在 parent 越界翻滚复位前松手)——

让咱们看看 第2步 产生了什么——

  1. 由于 nestedScroll 的缘故,往上抛掷 先交由 parent 过目
  • parent 打眼一看:我正在 越界状况,所以它排了个计划表——
    1. 抛掷(fling)有个 向上的速度velocity),我用这个速度恢复我的越界状况
    2. 越界状况恢复到 0 位(是零耶),但速度还有剩下,接下来我又有两个选择——
      1. 用这个速度持续越界翻滚,让全体界面上移
      2. 问问 child ,这个速度 你要、仍是不要
  • parent 具有朴素的社会主义价值观,他知道别人借自己用的东西,自己只用够用的就好了。剩下的、仍是得还回去,下次他才有的借
  1. parent 消费(consume)掉了一部分速度(velocity)。
  2. parent 把剩下的速度还给了 child

child 拿着 parent 还回来的速度,发现自己朝着速度的方向滑动

  1. child 决定往那个方向散步散步。

状况3:child fling -> parent overscroll -> child fling -> parent overscroll

这一次咱们做和前次 状况2 相同的操作。

但不同的是,咱们 往上丢 的时分 大力点——

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

产生了和前面相同的状况 1 2 3 4 自不必说。

咱们发现出现了新状况:

  • child 滑动过程中,发现自己现已到鸿沟了 ——没有更多内容需求显示了。

所以它问 parent :那你还要么。

parent : 义无反顾。

MIUI 存在什么问题?

为了避免辩解说 设置 app 是 ListView 做的,所以速度匹配不怎样好,咱们运用体系运用:文件办理做演示。

  • 这总是 recyclerView 做的了吧。
  • 当然,MIUI 上 ListView 和 RecyclerView 的行为几乎一致,这一点提出表彰。

值得指出的是,我都是用比较慢的速度进行一个【往回丢】,这样速度变化会看起来更显着。

看卡顿的 gif 图,结合我的描绘,各位能知道个大概——

所以啥时分支撑我上传视频?支撑10M以下就够啊

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

速度问题在代码上出现得比较严重,但好在人工调参调得比较好,用户感知并不特别显着/能接受——

  1. gif 中,第一次往下丢的时分,鸿沟处存在速度突增(设置app中体现更显着)。
    • 大概是 500 -> 800 的这种感觉

第二次滑动是 上拉后直接松手,使得界面复位到最底部。

  1. 第三次滑动,我又做了个往下丢的手势(速度不高),可以看到 速度被吞了 ,直接比较僵硬地进行了回弹。

欢迎用我 github 上的 demo 合作 MIUI 手机自己对比 :P

ColorOS 存在什么问题?

总结一句话:丢了。

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

  • 在体系设置app中,【往回丢】这个动作的速度彻底被吞,界面被弹簧以 0 的初速度拉回平衡点
  • 在这个 gif 展示的文件办理 app 中
    • 绝对值较小的速度被吞。
    • 高速会直接触发一个看起来是【固定速度的抛掷】。

总归便是不跟手,越滑越难过、很难过。


看到这儿,看官们对于咱们的 希望作用寻求目标 应该心里有数了。

那么咱们开端吧

Modifier.nestedScroll

嵌套翻滚的中心是 位移传递/消费速度传递/消费

完结 compose 版别前,无妨想想: View 中该怎样做

老规矩先拉踩一番 ——显然 View 的嵌套翻滚是个很复杂的东西。

诸君,瞧瞧那 view 完结这个得写多少接口吧!

  • child

    Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

  • parent

    Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

得,看到这儿、大家必定是两手一摊:哦豁见鬼。

这还仅仅接口,咱们还得读一堆 API 说明吧?

读完说明,不了解的当地还得看看运转逻辑或者找找法子吧?

加上最终动画作用的完结……简直想想就失望

幸而 google 爱世人,发明了 Compose 解救咱们于水火

不敢想不敢想…… 仍是直接看 Compose 中的做法吧

  • 就俩参数。
  • 只需写一个该 Modifier ,你的组件就可以同时支撑作为 childparent
    Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)
    看起来似乎一大串看不懂吼?

没事,我就放这,你跟完后边的内容,回头来看就能get到了——

这儿说了一堆废话、不如直接去看接口注释

参数0:NestedScrollDispatcher

这个东西放后边讲就晚了。

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

来,看看码

总结放前面,方便你带着了解看代码:这个东西是用来告诉 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?(完结)