前言
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 ,一样可以使得它正确处理嵌套滚动情况。
万事大吉了吗?
咱赶紧试试——
- 未能立即以脱手速度回弹
- 白等一会儿,然后以 0 速度回弹
——好像有点诡异哈?
根据我们的实现,应当在松手那一刻,使用 松手时的速度 + 弹簧参数 进行回弹。
怎么会这么“迟钝”?
尝试定位
笔者是在 compose 升级到 1.4.0-alpha02 之后发现的这个bug,之后忙于工作一直未处理,也就没法查明详细原因,只是建议不要着急使用这之后的新版本(1.3.x体验完美)。
后来倒是发现 1.3.0 修复过的 速度错误 又一次出现了,于是向谷歌提交了以下几个 issues :
- 这个,向谷歌用原生组件重现了速度错误
- 这个,有空之后我详细确认了一番,甚至短暂追了下源码、和之前修复错误的提交——果然,用新版本 compose 无法通过【修复速度错误提交时的测试用例】了,用例的执行报错会明确指出存在【速度反向】的错误
第二个 issue 链接明确指明了出错的 case ,但奈何谷歌不修复。——好在对于我们而言,一般情况下也察觉不到。
发现问题
但我查看 LazyColumn 有没有改变实现时,发现 Modifier.scrollable()
相关接口在后续版本中新增了 flingBehavior 参数,以至于 LazyColumn 也同样新增了此参数。
flingBehavior
这是个啥?先看注释——
这里第一句有翻译错误,应该译为【当 scrollable 中的拖动 结束时 存在速度的话,将xxxx】。
再结合上面的现象——好么,这不正符合【拖动的 flingBehavior 一直在消费速度,等到速度消费完了才告知我们】么。
——所以我们才会【白等很久】,再【以 0 速度而非脱手速度回弹】。
默认实现
很幸运,我们一眼就能看见它的默认实现。
再点进去——
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
——
理想很美好,但我们发现每次传入给 scrollBy() 的 delta 都被原样返回了——哪怕列表已经滚动到尽头,滚不动了。
也就是说, consumed == delta 始终为 true。
我们照着代码打log就会发现上述问题,所以我今日提出此 issue: issuetracker.google.com/issues/2967…
含泪自行修复一下
发现问题后,修复自然也简单——
- 拿到scrollState
- 每一动画帧都判断能否朝着当前速度方向滚动
- 如果能,继续正常逻辑。
- 如果不能,中止动画,并返回最后一次获取到的速度。
这个判断即:
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 更多的花样。