# Android Jetpack Compose 自定义 iOS 风格 Bounce 回弹:基于 OverscrollEffect 实现 LazyColumn 越界滚动效果
Android Jetpack Compose 自定义 iOS 风格 Bounce 回弹:基于 OverscrollEffect 实现 LazyColumn 越界滚动效果
本文面向初学者,解释当前文件 BounceOverscrollModifier.kt 如何基于 Jetpack Compose 的 OverscrollEffect,实现一个“参考 iOS 风格”的 bounce(越界回弹)效果。
本文内容主要基于两类材料:
- Android 官方文档与 API Reference。
- 本文所讨论的实现方案与示例代码。
1. 先看效果:默认 Overscroll vs 自定义 iOS 风格 Bounce
先看效果,再看实现,会更容易理解这篇文章后面的代码。
1.1 Compose 默认 Overscroll 效果
这一段要表达的是:LazyColumn默认就有 overscroll,只是默认手感不是本文要实现的 iOS 风格 bounce。
1.2 自定义 iOS 风格 Bounce 效果
这套实现完成后,主要能看到 3 个变化:
- 到达顶部或底部后,可以继续轻柔拉出边界。
- 越界拖动越深,阻尼越明显。
- 松手后不是直接归位,而是通过弹簧动画回弹。
1.3 这篇文章会讲什么
接下来的内容会按这个顺序展开:
- 先给出最小接入方式,说明新的 bounce 该怎么用。
- 再说明 Compose 默认的
OverscrollEffect是什么。 - 最后重点拆解
BounceOverscrollEffect的核心方法和回弹算法。
2. 新的 Bounce 应该如何使用?
如果只是想先落地效果,接入代码其实很少,关键只有两步:
- 创建
BounceOverscrollController - 把
bounceController.overscrollEffect传给LazyColumn
最小接入代码如下:
val bounceController = rememberBounceOverscrollController(config = BouncePresets.IOSLike)
Box(
modifier = Modifier
.fillMaxSize()
.bounceOverscroll(bounceController)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
overscrollEffect = bounceController.overscrollEffect,
) {
// ...
}
}
这里分工很明确:
-
LazyColumn(overscrollEffect = ...):负责把 scroll / fling 的剩余量交给自定义BounceOverscrollEffect -
Modifier.bounceOverscroll(...):负责记录容器高度,并让越界位移、缩放和回弹效果能正确生效
如果文章只记一个接入结论,可以先记这一句:
LazyColumn负责正常滚动,BounceOverscrollEffect负责边界外那一小段。
3. Jetpack Compose 默认的 Bounce / Overscroll 是什么?LazyColumn 默认是否支持?
3.1 Compose 默认就有 OverscrollEffect
Compose 官方文档对 OverscrollEffect 的定义很明确:
-
OverscrollEffect表示“当滚动容器到达边缘时显示的视觉效果”。 - 如果要拿到当前默认的 overscroll 实现,可以使用
rememberOverscrollEffect()。 - 像
LazyColumn这样的高层组件,会自动配置一个OverscrollEffect。
也就是说,在 Compose 里:
LazyColumn默认就支持 overscroll- 业务层不需要先从零实现一套 bounce 机制
3.2 Compose 默认实现和自定义实现的关系
如果不传自定义的 overscrollEffect,LazyColumn 会使用 Compose 当前提供的默认实现。
如果你希望做出不同手感,比如更接近 iOS 的 bounce 回弹,那么就可以传入自定义的 OverscrollEffect。
这也是这篇文章要做的事情:
-
LazyColumn本来就有默认 overscroll 行为 - 本文不是从零发明 overscroll
- 而是在 Compose 默认能力之上,自定义一套更接近 iOS 风格的 bounce 物理和视觉表达
3.3 官方文档里 OverscrollEffect 的几个关键信息
结合官方 API Reference,OverscrollEffect 有 3 个要点特别重要:
-
applyToScroll(...):处理 scroll 阶段的 overscroll。 -
applyToFling(...):处理 fling 阶段的 overscroll。 -
node:负责把 overscroll 的视觉效果真正渲染出来。
这也正好对应本文后面要讲的 BounceOverscrollEffect:
- 用
applyToScroll()接住边界剩余位移 - 用
applyToFling()处理松手后的剩余速度 - 用
node把状态变成位移、缩放和回弹
4. 自定义 iOS 风格 Bounce 回弹实现后,效果是什么样的?
这套实现完成后,实际呈现出来的效果主要包括以下几点。
4.1 LazyColumn 到达顶部或底部后,可以继续轻柔拉出边界
正常情况下,列表先由 LazyColumn 自己消费滚动。只有当列表已经到顶或到底、继续拖动还有剩余位移时,这个剩余位移才会进入自定义 bounce 逻辑。
所以用户体感是:
- 在内容还没到边界时,滚动完全正常。
- 到达边界后继续拖动,内容不会立刻“撞墙停住”,而是会被继续带出一点距离。
4.2 越界拖动越深,阻尼越明显
当前实现没有把“原始越界量”直接显示到界面上,而是先累计到 rawOverscrollY,然后通过一个 rubber-band 公式换算成最终显示位移:
(rawDistance * viewportHeightPx * config.rubberBandConstant) /
(viewportHeightPx + config.rubberBandConstant * rawDistance)
这个公式的特点是:
- 越拖越远时,显示位移仍然增加。
- 但增加速度会越来越慢。
- 所以用户主观感受会是“越来越难拉开”,也就是典型的橡皮筋阻尼感。
4.3 松手后会回弹,而不是直接归位
当前文件在 applyToFling() 中调用 Animatable.animateTo(),目标值是 0f,并且使用 spring(...) 作为动画规格。
这表示松手后不是瞬间归零,而是通过弹簧动画回到原位。
这与 iOS 风格的“边界外有张力,松手后回弹”比较接近。
GIF 预留位置 3:拖出边界后松手回弹
建议内容:手指缓慢把列表拖出顶部边界,然后松手,展示回弹过程。
这一张适合对应applyToFling()的讲解。
4.4 从中间高速 fling 到边界,也会触发一段 bounce 回弹
当前实现不仅处理“手指拖出了边界”,还处理“高速 fling 到边界后,列表没消费掉的剩余速度”。
代码里这部分逻辑是:
val consumed = performFling(velocity)
val remaining = velocity - consumed
然后把 remaining.y 按配置比例换算成回弹初速度,再驱动弹簧动画。
因此,用户从列表中间快速甩到顶部或底部时,也会看到一段更自然的回弹,而不是只在手指慢慢拖出边界时才有反馈。
4.5 最终视觉表现:位移 + 轻微缩放
node 的 measure() 里会把内容:
- 沿 Y 轴做位移
- 在 X 轴做轻微压缩
- 在 Y 轴做轻微拉伸
核心代码如下:
placeable.placeRelativeWithLayer(0, y) {
scaleX = 1f - (progress * config.scaleXShrinkFactor)
scaleY = 1f + (progress * config.scaleYStretchFactor)
transformOrigin =
if (visualOffset < 0f) TransformOrigin(0.5f, 0f)
else TransformOrigin(0.5f, 1f)
}
这说明这套实现的主要视觉效果是:
- 整体内容沿边界方向被带出去。
- 内容会有轻微“横向收缩、纵向拉伸”。
- 松手后通过弹簧动画回到原位。
GIF 预留位置 4:高速 fling 撞边 + 位移缩放效果
建议内容:从列表中间快速 fling 到顶部或底部,展示撞边后的 bounce,以及轻微缩放。
这一张适合放在视觉表现小节后面。
4.6 补充说明:边缘提示参数已预留,但当前版本未真正绘制
BounceOverscrollConfig 中定义了:
indicatorMaxAlphaindicatorHeightindicatorLeadingAlphaindicatorMiddleAlpha
并且 BounceOverscrollController 也维护了 indicatorAlphaState。
但从实现结构来看,这些状态目前只负责更新,还没有进入真正的绘制流程。
因此,严格来说,这版实现已经落地的效果是“位移 + 缩放 + 弹簧回弹”;
而“顶部/底部渐变提示层”目前更像是预留能力,还不是最终对外呈现的完整视觉层。
5. 核心实现:BounceOverscrollEffect 如何自定义 OverscrollEffect
如果只看最核心的代码,重点就是这个类:
private class BounceOverscrollEffect(
private val controller: BounceOverscrollController,
private val scope: CoroutineScope,
private val config: BounceOverscrollConfig,
) : OverscrollEffect {
private val rawOverscrollY = Animatable(0f)
override fun applyToScroll(
delta: Offset,
source: NestedScrollSource,
performScroll: (Offset) -> Offset,
): Offset { ... }
override suspend fun applyToFling(
velocity: Velocity,
performFling: suspend (Velocity) -> Velocity,
) { ... }
override val isInProgress: Boolean
get() = abs(rawOverscrollY.value) > OverscrollActiveThresholdPx
override val node: DelegatableNode = ...
private fun visualOffsetY(): Float { ... }
private fun stretchProgress(currentVisualOffset: Float = visualOffsetY()): Float = ...
private fun updateIndicator() {
controller.updateIndicator(visualOffsetY())
}
}
这个类可以直接按职责拆成 5 部分:
-
rawOverscrollY:保存原始越界量。 -
applyToScroll():处理拖动时的边界剩余位移。 -
applyToFling():处理松手后的剩余速度和回弹。 -
node:把越界量变成真实的位移和缩放效果。 -
visualOffsetY():用 rubber-band 公式,把原始越界量映射成最终显示位移。
6. BounceOverscrollEffect 关键方法详解
6.1 rawOverscrollY:保存原始越界量
private val rawOverscrollY = Animatable(0f)
这个值不是最终显示出来的位移,而是“原始越界距离”。
这样设计的好处是:
-
applyToScroll()和applyToFling()只负责更新状态。 -
visualOffsetY()统一负责把状态转换成视觉位移。 - 拖动阶段和回弹阶段可以共用一套显示公式。
6.2 applyToScroll():处理拖动阶段的 Overscroll
这个方法是整个实现里最重要的一个入口。它做的事情可以概括成一句话:
先让列表正常滚;如果列表已经到边界、滚不动了,剩下那部分位移就交给 bounce。
完整方法如下:
/**
* 处理“拖动阶段”的 overscroll。
*
* 这几个参数的含义可以这样理解:
*
* - [delta] 本次 scroll 事件总共带来了多少位移。 在当前纵向列表场景里,重点关注的是 delta.y。
*
* - [source] 这次 scroll 来自哪里。当前实现里主要关心是否是 [NestedScrollSource.UserInput],
* 也就是用户手指直接拖动产生的滚动。
*
* - [performScroll] 把某一部分位移交给真正的滚动容器(这里就是 LazyColumn)去消费。
* 你可以把它理解成:“我这里还有一些 scroll 预算,你列表先吃,吃完告诉我你吃了多少。”
*
* 返回值表示:
* - 这一次 applyToScroll 整体一共消费了多少位移
* - 这里返回的是:
* 1. overscroll 在正式滚动前预先消费的部分
* 2. LazyColumn 真正消费掉的部分 两者之和
*
* 当前实现的处理顺序是:
* 1. 如果已经有一段 overscroll,且用户开始反方向拖动,先优先“释放已有 overscroll”
* 2. 把剩余位移交给 LazyColumn 正常滚动
* 3. 如果 LazyColumn 已经到边界,吃不完本次位移,剩余部分就记到 rawOverscrollY
* 4. 最终显示位移不直接使用 rawOverscrollY,而是走 rubber-band 公式做阻尼映射
*/
override fun applyToScroll(
delta: Offset,
source: NestedScrollSource,
performScroll: (Offset) -> Offset,
): Offset {
val sameDirection = sign(delta.y) == sign(rawOverscrollY.value)
val consumedBeforeScroll =
if (abs(rawOverscrollY.value) > OverscrollActiveThresholdPx && !sameDirection) {
// 如果现在已经处于越界状态,且用户开始往回拖,
// 就优先“释放已有的越界量”,而不是立刻交给列表正常滚动。
//
// 这里算出来的 consumedBeforeScroll,可以理解成:
// “在真正交给 LazyColumn 之前,overscroll 自己先吃掉了多少位移”。
val previous = rawOverscrollY.value
val next = previous + delta.y
if (sign(previous) != sign(next)) {
// previous 和 next 符号不同,说明这一次反向拖动已经足够把
// 当前 overscroll 完全抵消掉,甚至还会“拖过头”。
//
// 例如:
// - 当前顶部 overscroll = -30
// - 本次 delta.y = +50(往回拖)
// - next = -30 + 50 = +20
//
// 这表示 overscroll 已经不只是被释放为 0,
// 而是还有一部分剩余位移应该继续交给 LazyColumn 去正常滚动。
//
// 所以这里要做两件事:
// 1. 把 rawOverscrollY 直接归零,表示“边界外那段位移已经收完了”
// 2. 返回 Offset(x = 0f, y = delta.y + previous)
// 表示 overscroll 只消费了“把 previous 拉回 0”这一段
// 剩余部分将留给后面的 performScroll(leftForScroll)
scope.launch {
rawOverscrollY.snapTo(0f)
updateIndicator()
}
Offset(x = 0f, y = delta.y + previous)
} else {
// previous 和 next 仍然同号,说明这一次反向拖动还不够把
// overscroll 完全消掉,只是把它缩小了一部分。
//
// 例如:
// - 当前顶部 overscroll = -30
// - 本次 delta.y = +10(往回拖)
// - next = -20
//
// 这时应该把这一整次 delta 都优先用来“释放 overscroll”,
// 而不是让 LazyColumn 立刻开始正常滚动。
//
// 所以这里:
// 1. 把 rawOverscrollY 更新为 next
// 2. 返回 Offset(x = 0f, y = delta.y)
// 表示本次位移全部被 overscroll 自己消费掉了
scope.launch {
rawOverscrollY.snapTo(next)
updateIndicator()
}
Offset(x = 0f, y = delta.y)
}
} else {
Offset.Zero
}
// leftForScroll:
// 本次总 delta 里,扣掉 overscroll 预先消费的部分后,
// 还剩多少可以真正交给 LazyColumn 去滚。
val leftForScroll = delta - consumedBeforeScroll
// consumedByScroll:
// LazyColumn 真正消费掉了多少位移。
// 如果列表还没到边界,通常它能吃掉大部分甚至全部。
val consumedByScroll = performScroll(leftForScroll)
// overscrollDelta:
// 交给 LazyColumn 之后,列表没吃掉、剩下来的那部分。
// 这个值一旦不为 0,通常就说明已经撞到边界了。
val overscrollDelta = leftForScroll - consumedByScroll
if (abs(overscrollDelta.y) > OverscrollActiveThresholdPx &&
source == NestedScrollSource.UserInput
) {
scope.launch {
// performScroll 没吃掉的那部分,说明已经顶到边界了。
// 这时候把剩余 delta 记到 rawOverscrollY,交给越界效果显示。
//
// 注意:这里没有直接对 overscrollDelta 做“视觉阻尼削减”。
// 当前实现是先完整累计原始越界量,
// 再在 visualOffsetY() 里统一通过 rubber-band 公式计算最终显示位移。
rawOverscrollY.snapTo(rawOverscrollY.value + overscrollDelta.y)
updateIndicator()
}
}
// 返回“本次总共消费了多少”:
// 1. overscroll 在前置阶段吃掉的
// 2. LazyColumn 在正常滚动阶段吃掉的
//
// 而 overscrollDelta 这部分则被转成了 rawOverscrollY 状态,
// 后续通过 node 和 visualOffsetY() 体现在界面上。
return consumedBeforeScroll + consumedByScroll
}
这段逻辑可以概括成 4 个关键点:
- 先释放已有 overscroll。
- 再交给
LazyColumn正常滚动。 - 列表吃不掉的剩余位移,累计到
rawOverscrollY。 - 最后返回总消费量。
6.3 applyToFling():处理 fling 和松手回弹
applyToScroll() 解决的是“拖动时怎么办”,applyToFling() 解决的是“松手后怎么办”。
完整方法如下:
override suspend fun applyToFling(
velocity: Velocity,
performFling: suspend (Velocity) -> Velocity,
) {
// hadDragOverscroll = true:
// 表示用户已经把内容拖到边界外了,这时松手应该走“边界释放”的那套回弹。
//
// hadDragOverscroll = false:
// 表示用户是从中间快速滑到边界,这时 bounce 主要由 fling 剩余速度驱动。
val hadDragOverscroll = abs(rawOverscrollY.value) > OverscrollActiveThresholdPx
val consumed = performFling(velocity)
// remaining 就是列表没消费掉的速度。
// 根据 OverscrollEffect 的设计,这部分速度应该交给 overscroll 来处理。
val remaining = velocity - consumed
val reboundVelocity =
if (hadDragOverscroll) {
remaining.y * config.dragReleaseVelocityFactor
} else {
remaining.y * config.flingReleaseVelocityFactor
}
rawOverscrollY.animateTo(
targetValue = 0f,
initialVelocity = reboundVelocity,
animationSpec =
spring(
dampingRatio =
if (hadDragOverscroll) config.dragReleaseDampingRatio
else config.flingReleaseDampingRatio,
stiffness =
if (hadDragOverscroll) config.dragReleaseStiffness
else config.flingReleaseStiffness,
),
)
updateIndicator()
}
这一段可以归纳成 3 个关键点:
- 先执行
performFling(velocity),让列表自己先处理 fling。 - 取出
remaining = velocity - consumed,也就是列表没吃掉的剩余速度。 - 用这个剩余速度作为弹簧动画的初速度,把
rawOverscrollY拉回0f。
这里之所以要区分 hadDragOverscroll,是因为它要区分两种手感:
- 已经拖到边界外再松手
- 从中间高速 fling 到边界
6.3.1 最终回弹算法
最终回弹并不是简单地把位移直接重置为 0f,而是按下面这套流程执行:
val hadDragOverscroll = abs(rawOverscrollY.value) > OverscrollActiveThresholdPx
val consumed = performFling(velocity)
val remaining = velocity - consumed
val reboundVelocity =
if (hadDragOverscroll) {
remaining.y * config.dragReleaseVelocityFactor
} else {
remaining.y * config.flingReleaseVelocityFactor
}
rawOverscrollY.animateTo(
targetValue = 0f,
initialVelocity = reboundVelocity,
animationSpec =
spring(
dampingRatio =
if (hadDragOverscroll) config.dragReleaseDampingRatio
else config.flingReleaseDampingRatio,
stiffness =
if (hadDragOverscroll) config.dragReleaseStiffness
else config.flingReleaseStiffness,
),
)
这一段可以直接整理成 4 步:
- 先执行
performFling(velocity),让LazyColumn先消费它自己能消费的 fling 速度。 - 用
remaining = velocity - consumed算出列表没有消费掉的剩余速度。 - 把剩余速度按比例折算成
reboundVelocity,作为回弹动画的初速度。 - 调用
rawOverscrollY.animateTo(targetValue = 0f, animationSpec = spring(...)),让越界量通过弹簧动画回到0f。
如果写成更直白的“算法表达”,就是:
最终回弹速度 = 剩余 fling 速度 * 回弹速度系数
最终回弹目标 = 0f
最终回弹动画 = spring(dampingRatio, stiffness)
这里最关键的是 3 个量:
-
remaining:列表没吃掉的剩余速度 -
reboundVelocity:真正喂给回弹动画的初速度 -
spring(dampingRatio, stiffness):决定回弹是更软、更弹,还是更快、更稳
也就是说,这份实现里的“最终回弹算法”本质上是:
- 先拿到边界剩余速度
- 再把它转换成回弹初速度
- 最后用弹簧动画把
rawOverscrollY拉回 0
其中 dampingRatio 和 stiffness 分别决定:
-
dampingRatio:回弹时是更柔和还是更有弹性 -
stiffness:回到原位的速度是更快还是更慢
因此,最终的回弹效果并不是由单一公式决定,而是由下面这组组合决定:
回弹效果 =
剩余速度 remaining
-> 回弹初速度 reboundVelocity
-> spring(dampingRatio, stiffness)
-> rawOverscrollY 回到 0f
6.4 isInProgress:判断当前 Overscroll 是否仍在进行中
override val isInProgress: Boolean
get() = abs(rawOverscrollY.value) > OverscrollActiveThresholdPx
这个属性的作用很直接:
- 只要越界量还大于阈值,就认为 overscroll 还在进行中。
- 小于阈值就认为已经结束。
文件里把阈值单独写成了:
private const val OverscrollActiveThresholdPx = 0.5f
这样做是为了避免 spring 动画接近 0f 时那段很长的尾巴,影响后续手势判断。
6.5 node:真正把 Bounce 位移和缩放渲染出来
前面的几个方法只是“更新状态”,真正把效果画出来的是 node:
override val node: DelegatableNode =
object : Modifier.Node(), LayoutModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
val visualOffset = visualOffsetY()
val y = visualOffset.roundToInt()
val progress = stretchProgress(visualOffset)
placeable.placeRelativeWithLayer(0, y) {
scaleX = 1f - (progress * config.scaleXShrinkFactor)
scaleY = 1f + (progress * config.scaleYStretchFactor)
transformOrigin =
if (visualOffset < 0f) TransformOrigin(0.5f, 0f)
else TransformOrigin(0.5f, 1f)
}
}
}
}
这个 node 做了两件关键事情:
- 根据
visualOffsetY()把内容整体向上或向下偏移。 - 根据
progress做轻微缩放,让手感更像橡皮筋。
其中:
- 顶部越界时,
transformOrigin设在顶部。 - 底部越界时,
transformOrigin设在底部。
这样拉伸方向会更自然。
6.6 visualOffsetY():把原始越界量映射成最终显示位移
这个方法非常关键,因为它决定了“为什么越拖阻尼越大”。
private fun visualOffsetY(): Float {
val rawDistance = abs(rawOverscrollY.value)
val viewportHeightPx = controller.viewportHeightPx
val rubberBandOffset =
(rawDistance * viewportHeightPx * config.rubberBandConstant) /
(viewportHeightPx + config.rubberBandConstant * rawDistance)
return sign(rawOverscrollY.value) * rubberBandOffset
}
文件注释里已经解释得很好,这里可以直接抓住 3 点:
-
rawOverscrollY存的是原始越界量。 -
visualOffsetY()才是最终显示位移。 - 公式增长越来越慢,所以阻尼会越来越强。
也正因为这样,applyToScroll() 不直接做视觉阻尼,而是先累计原始量,再统一映射。
6.7 stretchProgress() 与 updateIndicator():辅助进度和状态同步
这两个方法都是辅助方法。
private fun stretchProgress(currentVisualOffset: Float = visualOffsetY()): Float =
(abs(currentVisualOffset) /
(controller.viewportHeightPx * config.stretchProgressViewportFraction))
.coerceIn(0f, 1f)
private fun updateIndicator() {
controller.updateIndicator(visualOffsetY())
}
作用分别是:
-
stretchProgress():把当前位移转换成0f..1f的进度值,给缩放效果使用。 -
updateIndicator():把当前视觉位移同步给 controller,方便外部维护边缘状态。
7. 总结:如何在 Compose 中实现 iOS 风格 Bounce 回弹
这份实现最值得看的不是参数表,而是 BounceOverscrollEffect 这一个类。
阅读顺序建议就是:
- 先看
applyToScroll(),理解拖动时如何接住边界剩余位移。 - 再看
applyToFling(),理解松手后如何回弹。 - 再看
node,理解视觉位移和缩放是怎么渲染出来的。 - 最后看
visualOffsetY(),理解 rubber-band 公式为什么会带来“越拖越重”的手感。
如果把它浓缩成一句话:
applyToScroll()负责接住边界剩余位移,applyToFling()负责把剩余速度变成回弹,node负责把这些状态真正显示出来。
8. 参考资料
以下资料均为本文的直接依据:
-
Android Developers,
OverscrollEffectAPI Reference
https://developer.android.com/reference/kotlin/androidx/compose/foundation/OverscrollEffect -
Android Developers,
Modifier.overscrollAPI Reference
https://developer.android.com/reference/kotlin/androidx/compose/foundation/overscroll.modifier -
Android Developers,
Animate a scroll gesture
https://developer.android.com/develop/ui/views/touch-and-input/gestures/scroll -
Android Developers,
Behavior changes: all apps->Stretch overscroll effect
https://developer.android.com/about/versions/12/behavior-changes-all -
Android Developers,
EdgeEffectAPI Reference
https://developer.android.com/reference/android/widget/EdgeEffect
需要完整代码,点个关注,多多支持,请私信我
作者: hzh
邮箱: 911hzh@gmail.com
日期: 2026-04-22

