# Android Jetpack Compose 自定义 iOS 风格 Bounce 回弹:基于 OverscrollEffect 实现 LazyColumn 越界滚动效果

AI15小时前发布 beixibaobao
4 0 0

Android Jetpack Compose 自定义 iOS 风格 Bounce 回弹:基于 OverscrollEffect 实现 LazyColumn 越界滚动效果

本文面向初学者,解释当前文件 BounceOverscrollModifier.kt 如何基于 Jetpack Compose 的 OverscrollEffect,实现一个“参考 iOS 风格”的 bounce(越界回弹)效果。

本文内容主要基于两类材料:

  1. Android 官方文档与 API Reference。
  2. 本文所讨论的实现方案与示例代码。

1. 先看效果:默认 Overscroll vs 自定义 iOS 风格 Bounce

先看效果,再看实现,会更容易理解这篇文章后面的代码。

1.1 Compose 默认 Overscroll 效果

请添加图片描述


这一段要表达的是:LazyColumn 默认就有 overscroll,只是默认手感不是本文要实现的 iOS 风格 bounce。

1.2 自定义 iOS 风格 Bounce 效果

请添加图片描述


这套实现完成后,主要能看到 3 个变化:

  1. 到达顶部或底部后,可以继续轻柔拉出边界。
  2. 越界拖动越深,阻尼越明显。
  3. 松手后不是直接归位,而是通过弹簧动画回弹。

1.3 这篇文章会讲什么

接下来的内容会按这个顺序展开:

  1. 先给出最小接入方式,说明新的 bounce 该怎么用。
  2. 再说明 Compose 默认的 OverscrollEffect 是什么。
  3. 最后重点拆解 BounceOverscrollEffect 的核心方法和回弹算法。

2. 新的 Bounce 应该如何使用?

如果只是想先落地效果,接入代码其实很少,关键只有两步:

  1. 创建 BounceOverscrollController
  2. 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 默认实现和自定义实现的关系

如果不传自定义的 overscrollEffectLazyColumn 会使用 Compose 当前提供的默认实现。
如果你希望做出不同手感,比如更接近 iOS 的 bounce 回弹,那么就可以传入自定义的 OverscrollEffect

这也是这篇文章要做的事情:

  • LazyColumn 本来就有默认 overscroll 行为
  • 本文不是从零发明 overscroll
  • 而是在 Compose 默认能力之上,自定义一套更接近 iOS 风格的 bounce 物理和视觉表达

3.3 官方文档里 OverscrollEffect 的几个关键信息

结合官方 API Reference,OverscrollEffect 有 3 个要点特别重要:

  1. applyToScroll(...):处理 scroll 阶段的 overscroll。
  2. applyToFling(...):处理 fling 阶段的 overscroll。
  3. 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 最终视觉表现:位移 + 轻微缩放

nodemeasure() 里会把内容:

  • 沿 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)
}

这说明这套实现的主要视觉效果是:

  1. 整体内容沿边界方向被带出去。
  2. 内容会有轻微“横向收缩、纵向拉伸”。
  3. 松手后通过弹簧动画回到原位。

GIF 预留位置 4:高速 fling 撞边 + 位移缩放效果
建议内容:从列表中间快速 fling 到顶部或底部,展示撞边后的 bounce,以及轻微缩放。
这一张适合放在视觉表现小节后面。

4.6 补充说明:边缘提示参数已预留,但当前版本未真正绘制

BounceOverscrollConfig 中定义了:

  • indicatorMaxAlpha
  • indicatorHeight
  • indicatorLeadingAlpha
  • indicatorMiddleAlpha

并且 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 部分:

  1. rawOverscrollY:保存原始越界量。
  2. applyToScroll():处理拖动时的边界剩余位移。
  3. applyToFling():处理松手后的剩余速度和回弹。
  4. node:把越界量变成真实的位移和缩放效果。
  5. 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 个关键点:

  1. 先释放已有 overscroll。
  2. 再交给 LazyColumn 正常滚动。
  3. 列表吃不掉的剩余位移,累计到 rawOverscrollY
  4. 最后返回总消费量。

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 个关键点:

  1. 先执行 performFling(velocity),让列表自己先处理 fling。
  2. 取出 remaining = velocity - consumed,也就是列表没吃掉的剩余速度。
  3. 用这个剩余速度作为弹簧动画的初速度,把 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 步:

  1. 先执行 performFling(velocity),让 LazyColumn 先消费它自己能消费的 fling 速度。
  2. remaining = velocity - consumed 算出列表没有消费掉的剩余速度。
  3. 把剩余速度按比例折算成 reboundVelocity,作为回弹动画的初速度。
  4. 调用 rawOverscrollY.animateTo(targetValue = 0f, animationSpec = spring(...)),让越界量通过弹簧动画回到 0f

如果写成更直白的“算法表达”,就是:

最终回弹速度 = 剩余 fling 速度 * 回弹速度系数
最终回弹目标 = 0f
最终回弹动画 = spring(dampingRatio, stiffness)

这里最关键的是 3 个量:

  • remaining:列表没吃掉的剩余速度
  • reboundVelocity:真正喂给回弹动画的初速度
  • spring(dampingRatio, stiffness):决定回弹是更软、更弹,还是更快、更稳

也就是说,这份实现里的“最终回弹算法”本质上是:

  • 先拿到边界剩余速度
  • 再把它转换成回弹初速度
  • 最后用弹簧动画把 rawOverscrollY 拉回 0

其中 dampingRatiostiffness 分别决定:

  • 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 做了两件关键事情:

  1. 根据 visualOffsetY() 把内容整体向上或向下偏移。
  2. 根据 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 点:

  1. rawOverscrollY 存的是原始越界量。
  2. visualOffsetY() 才是最终显示位移。
  3. 公式增长越来越慢,所以阻尼会越来越强。

也正因为这样,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 这一个类。

阅读顺序建议就是:

  1. 先看 applyToScroll(),理解拖动时如何接住边界剩余位移。
  2. 再看 applyToFling(),理解松手后如何回弹。
  3. 再看 node,理解视觉位移和缩放是怎么渲染出来的。
  4. 最后看 visualOffsetY(),理解 rubber-band 公式为什么会带来“越拖越重”的手感。

如果把它浓缩成一句话:

applyToScroll() 负责接住边界剩余位移,applyToFling() 负责把剩余速度变成回弹,node 负责把这些状态真正显示出来。


8. 参考资料

以下资料均为本文的直接依据:

  1. Android Developers, OverscrollEffect API Reference
    https://developer.android.com/reference/kotlin/androidx/compose/foundation/OverscrollEffect

  2. Android Developers, Modifier.overscroll API Reference
    https://developer.android.com/reference/kotlin/androidx/compose/foundation/overscroll.modifier

  3. Android Developers, Animate a scroll gesture
    https://developer.android.com/develop/ui/views/touch-and-input/gestures/scroll

  4. Android Developers, Behavior changes: all apps -> Stretch overscroll effect
    https://developer.android.com/about/versions/12/behavior-changes-all

  5. Android Developers, EdgeEffect API Reference
    https://developer.android.com/reference/android/widget/EdgeEffect

需要完整代码,点个关注,多多支持,请私信我
作者: hzh
邮箱: 911hzh@gmail.com
日期: 2026-04-22

© 版权声明

相关文章