Compose 用 nestedScroll 实现iOS的回弹效果,还要帮谷歌修bug?(完结)

1,096 阅读4分钟

前言

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

上篇进行了整体脉络梳理(含github仓库链接)。

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

中篇实现了核心算法。

这一篇我们来替谷歌兜底 擦屁股

官方的贴心之处?

事实上,LazyColumn / LazyRow 内部实现了 nestedScroll()。

所以我们基于我们的封装,我们甚至只需要这样——

LazyColumn(Modifier
            .fillMaxSize()
            .overScrollVertical() // 新增一个我们的 Modifier
    ) {
       // ...
    }

就可以让 LazyColumn 直接支持我们预期的效果。


或者想让父布局嵌套滚动,那只需要这样——

 Column(Modifier
        .fillMaxSize()
        .overScrollVertical() // 新增一个我们的 Modifier
    ) {
        LazyColumn(Modifier
            .fillMaxWidth()
            .weight(1f)
        ) {
           // ...
        }
    }

就可以让父布局响应到 lazyColumn 用不完的滚动增量和滚动速度

  • 这里的父布局是 Column ,但也完全可以是任意形式的 Composable
  • 这里 LazyColumn 如果替换成 Column + Modifier.verticalScroll() 实现,只需要增加我们的 overscrollVertical modifier ,一样可以使得它正确处理嵌套滚动情况。

万事大吉了吗?

咱赶紧试试——

img_v2_fe5620a8-175e-4899-b00c-79f9d731717g.gif

  • 未能立即以脱手速度回弹
  • 白等一会儿,然后以 0 速度回弹

——好像有点诡异哈?

根据我们的实现,应当在松手那一刻,使用 松手时的速度 + 弹簧参数 进行回弹。

怎么会这么“迟钝”?

尝试定位

笔者是在 compose 升级到 1.4.0-alpha02 之后发现的这个bug,之后忙于工作一直未处理,也就没法查明详细原因,只是建议不要着急使用这之后的新版本(1.3.x体验完美)。

后来倒是发现 1.3.0 修复过的 速度错误 又一次出现了,于是向谷歌提交了以下几个 issues :

  • 这个,向谷歌用原生组件重现了速度错误
  • 这个,有空之后我详细确认了一番,甚至短暂追了下源码、和之前修复错误的提交——果然,用新版本 compose 无法通过【修复速度错误提交时的测试用例】了,用例的执行报错会明确指出存在【速度反向】的错误

第二个 issue 链接明确指明了出错的 case ,但奈何谷歌不修复。——好在对于我们而言,一般情况下也察觉不到。

发现问题

但我查看 LazyColumn 有没有改变实现时,发现 Modifier.scrollable() 相关接口在后续版本中新增了 flingBehavior 参数,以至于 LazyColumn 也同样新增了此参数。

flingBehavior

这是个啥?先看注释——

image.png

这里第一句有翻译错误,应该译为【当 scrollable 中的拖动 结束时 存在速度的话,将xxxx】。

再结合上面的现象——好么,这不正符合【拖动的 flingBehavior 一直在消费速度,等到速度消费完了才告知我们】么。

——所以我们才会【白等很久】,再【以 0 速度而非脱手速度回弹】。

默认实现

很幸运,我们一眼就能看见它的默认实现。 image.png 再点进去——

object ScrollableDefaults {
    /**
     * Create and remember default [FlingBehavior]
     * that will represent natural fling curve.
     */
    @Composable
    fun flingBehavior(): FlingBehavior {
        val flingSpec = rememberSplineBasedDecay<Float>()
        return remember(flingSpec) {
            DefaultFlingBehavior(flingSpec)
        }
    }
}

哦吼,好东西,用来帮我们快速替换 fling 曲线的。

几乎所有国产手机厂商都会重新在 Overscroller 中重新定义该曲线( 或者只在他们系统应用的列表组件中另行定制 ),以实现他们各自设计师眼中【更优雅的列表滑动】。

而且其默认decay也确实是 splineBasedDecay , 和 Android 原生中的默认行为保持一致。

所以再看 DefaultFlingBehavior ——

image.png

理想很美好,但我们发现每次传入给 scrollBy() 的 delta 都被原样返回了——哪怕列表已经滚动到尽头,滚不动了。

也就是说, consumed == delta 始终为 true

我们照着代码打log就会发现上述问题,所以我今日提出此 issue: issuetracker.google.com/issues/2967…

含泪自行修复一下

发现问题后,修复自然也简单——

  1. 拿到scrollState
  2. 每一动画帧都判断能否朝着当前速度方向滚动
    • 如果能,继续正常逻辑。
    • 如果不能,中止动画,并返回最后一次获取到的速度。

这个判断即:

velocityLeft < 0 && scrollState.canScrollBackward || 
velocityLeft > 0 && scrollState.canScrollForward

为 true 即表示【能继续滚】,为 false 则我们应终止流程。

所以有代码如下——

@Composable
fun rememberOverscrollFlingBehavior(
    decaySpec: DecayAnimationSpec<Float> = exponentialDecay(),
    getScrollState: () -> ScrollableState,
): FlingBehavior = remember(decaySpec) {
    object : FlingBehavior {
        // this is for Velocity
        private val Float.canNotBeConsumed: Boolean 
            get() {
                val state = getScrollState()
                return !(this < 0 && state.canScrollBackward ||
                    this > 0 && state.canScrollForward)
            }

        override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
            // 开头就判断一下,别进不必要的动画逻辑
            if (initialVelocity.canNotBeConsumed) {
                return initialVelocity
            }
            return if (abs(initialVelocity) > 1f) {
                var velocityLeft = initialVelocity
                var lastValue = 0f
                AnimationState(
                    initialValue = 0f,
                    initialVelocity = initialVelocity,
                ).animateDecay(decaySpec) {
                    val delta = value - lastValue
                    val consumed = scrollBy(delta)
                    lastValue = value
                    velocityLeft = this.velocity
                    // avoid rounding errors and stop if anything is unconsumed
                    // 动画中每一帧判断一次
                    if (abs(delta - consumed) > 0.5f ||
                        velocityLeft.canNotBeConsumed) {
                             this.cancelAnimation()
                        }
                }
                velocityLeft
            } else {
                initialVelocity
            }
        }
    }
}

摊手,真是为compose和谷歌爸爸操碎了心。

别忘了 点赞、收藏、关注 三连支持,带你了解 compose 更多的花样。