likes
comments
collection
share

Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

作者站长头像
站长
· 阅读数 15

前言

什么是嵌套滚动(nestedScroll)的理想状态?

我们到底想做成什么样子?不妨跟着我来看以下几个问题——

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

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

理想状况

以下是咱们【越界回弹效果】面对【嵌套滚动场景】下的【理想状况】。

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

情况1:child scroll -> parent overscroll

我们来看个图—— Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现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 越界滚动复位前松手)——

Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

让我们看看 第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 lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

发生了和前面一样的情况 1 2 3 4 自不必说。

我们发现出现了新状况:

  • child 滑动过程中,发现自己已经到边界了 ——没有更多内容需要显示了。

于是它问 parent :那你还要么。

parent : 义不容辞。

MIUI 存在什么问题?

为了防止辩解说 设置 app 是 ListView 做的,所以速度匹配不怎么好,咱们使用系统应用:文件管理做演示。

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

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

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

所以啥时候支持我上传视频?支持10M以下就够啊 Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

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

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

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

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

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

ColorOS 存在什么问题?

总结一句话:丢了。

Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

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

总之就是不跟手,越滑越难受、很难受。


看到这里,看官们对于我们的 期望效果追求目标 应该心里有数了。

那么我们开始吧

Modifier.nestedScroll

嵌套滚动的核心是 位移传递/消费速度传递/消费

实现 compose 版本前,不妨想想: View 中该怎么做

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

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

  • child

    Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

  • parent

    Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中)

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

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

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

加上最终动画效果的实现……简直想想就绝望

幸亏 google 爱世人,创造了 Compose 拯救我们于水火

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

  • 就俩参数。
  • 只需写一个该 Modifier ,你的组件就能够同时支持作为 childparentJetpack Compose lazyList/列表/任意可滚动场景用modifier实现iOS的回弹动画(中) 看起来似乎一大串看不懂吼?

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

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

参数0:NestedScrollDispatcher

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

Jetpack Compose lazyList/列表/任意可滚动场景用modifier实现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 期啊!

这么多字和这篇文章的润色,足足费了我一整天啊!

点赞啊给我!收藏啊给我!不然我……

转载自:https://juejin.cn/post/7268553505705771062
评论
请登录