Skip to content

过度滚动 overScroll* — 边界回弹 ​

手指拖到列表顶端/底端之后还能再拽一段,松手用弹簧弹回去。

这套效果有两套入口:主题里默认全局注入的工厂版(MiuixOverscrollFactory),和可以单独挂到某个容器上的修饰符版(overScrollVertical / overScrollHorizontal / overScrollOutOfBound)。

基本用法 ​

通常什么都不用做 ​

HuaLuoTheme 内部已经把 LocalOverscrollFactory 提供成了 MiuixOverscrollFactory:

kotlin
// 主题内部的实现,不用你写
CompositionLocalProvider(LocalOverscrollFactory provides MiuixOverscrollFactory) { … }

所以只要界面在主题里,LazyColumn / LazyRow / horizontalScroll / verticalScroll 就已经有回弹了,不需要再挂任何东西。

单轴挂到容器上 ​

容器不走 Compose 的 OverscrollEffect 通路时(自己接管了 nestedScroll,或要按轴分别控制),才用修饰符:

kotlin
LazyColumn(
    state = listState,
    modifier = Modifier.overScrollVertical(),
) { /* … */ }

LazyRow(
    state = rowState,
    modifier = Modifier.overScrollHorizontal(isEnabled = { !isLoading }),
) { /* … */ }

三个参数都要自己控时用完整版:

kotlin
Modifier.overScrollOutOfBound(
    isVertical = false,
    nestedScrollToParent = false,
    isEnabled = { canDrag },
)

页内共享回弹状态(下拉刷新 / 折叠标题栏必须做) ​

下拉刷新和折叠标题栏都要知道「现在是不是正在回弹」。这一步得自己包,而且要包住整个页面:

kotlin
val overScrollState = remember { OverScrollState() }

CompositionLocalProvider(LocalOverScrollState provides overScrollState) {
    Column {
        CollapsingTitleScaffold(
            nestedScrollConnection = /* 读 overScrollState 的那个连接器 */,
        ) {
            PullToRefresh(...) {
                LazyColumn { /* … */ }
            }
        }
    }
}

参数 ​

Modifier.overScrollVertical / Modifier.overScrollHorizontal ​

参数类型默认值说明
nestedScrollToParentBooleantrue是否把嵌套滚动事件继续派发给父级
isEnabled() -> Boolean{ true }动态开关。给 lambda 而不是 Boolean
kotlin
Modifier.overScrollVertical()                                  // 竖轴、派发给父级
Modifier.overScrollVertical(nestedScrollToParent = false)      // 自己吃掉,不往上传
Modifier.overScrollHorizontal(isEnabled = { listState.canScrollForward })

Modifier.overScrollOutOfBound ​

上面两个都只是它的预设,完整参数是这三个:

参数类型默认值说明
isVerticalBooleantruetrue 竖向、false 横向
nestedScrollToParentBooleantrue同上的派发开关
isEnabled() -> Boolean{ true }同上

LocalOverScrollState / OverScrollState ​

成员类型说明
LocalOverScrollStateProvidableCompositionLocal<OverScrollState>页内共享的回弹状态;默认值是一个新实例(不 provide 也能跑,只是各读各的)
OverScrollState.isOverScrollActiveBoolean当前是否正在回弹。只读,由效果内部写

MiuixOverscrollFactory / MiuixOverscrollEffect ​

名称说明
MiuixOverscrollFactoryOverscrollFactory 的实现,由主题通过 LocalOverscrollFactory 注入,负责给每个滚动容器造一个效果实例
MiuixOverscrollEffect实际的 OverscrollEffect,横竖两轴各自结算、并和下拉刷新协调

两个都不用自己 new。

要点 ​

  • LocalOverScrollState 必须包住整页。 回弹状态只有一份:滚动容器的回弹、下拉刷新、折叠标题栏的 NestedScrollConnection 都要读同一个 OverScrollState。只包住列表那一层的话,标题栏永远读不到 isOverScrollActive —— 表现是「fling 到列表边界时标题栏不跟着动」。
  • 它只在「按下并拖动」的手势里生效。 触摸拖拽、触控板拖拽触发回弹;鼠标滚轮和键盘滚动完全不拦截,原样透传。桌面上不用担心它抢事件。
  • 非手势来源的滚动量会绕过 nestedScrollToParent 继续往上传(鼠标滚轮这类 pass-through,以及下拉刷新进行中)。也就是说这个参数管不住它们。
  • isEnabled 给 lambda,不要给 Boolean。 传值会在状态变化时重建整个修饰符;传 lambda 只更新读取。
  • 别在同一个容器上叠两层。 主题已经全局注入了一套,手动再挂 overScrollVertical() 会让位移被算两遍,手感变得很怪。只有容器不走 OverscrollEffect 通路时才需要手动挂。
  • 修饰符自带裁剪(内部有 clipToBounds()):它把内容平移到边界外再裁掉。所以不要挂在需要向外溢出的容器上,那层裁剪会连你的溢出一并切掉。
  • 横向回弹的位移上限按屏幕宽度算、竖向按屏幕高度算,不是按容器尺寸 —— 一个矮容器照样能拽出可观的位移。

说明 ​

  • 两套入口的分工:工厂版走 Compose 官方的 OverscrollEffect 通路,横竖两轴一起管,任何标准滚动容器自动生效;修饰符版是独立的 NestedScrollConnection,一次只管一个轴,给不走那条通路的容器用。两者共用同一套阻尼与弹簧参数,所以手感一致。
  • 和系统自带的 stretch / glow 的区别:系统那套是「边缘发一道光晕」(Android 12+ 变成拉伸整条边)。这套是把内容整体平移出去、再弹回来 —— 内容跟着手指走,松手回弹,更接近 iOS 的橡皮筋。要的就是这个观感,所以主题里直接把它设成了默认工厂。
  • 阻尼不是线性的。 位移映射按 x - x² + x³/3 走(x 是归一化输入):越往外拽越沉,到 x = 1 时位移是范围的 1/3。手感是「先跟手、后吃力」,不是一堵硬墙。
  • 弹簧是临界阻尼(阻尼比 1.0),不会来回震荡 —— 松手是一次干净的回弹,不晃第二下。
  • 弹簧周期随速度变:速度绝对值超过 5000 时周期从 0.4s 放慢到 0.55s。甩得越猛,回来得越"重"。另外 fling 交给弹簧的速度会先除以 1.53333 衰减一次。
  • 帧步长被夹在 0.001s ~ 0.016s:掉帧时弹簧不会因为单帧位移过大而穿模。
  • 和下拉刷新的协调是双向的:下拉刷新进行中时竖向回弹让位(RefreshState 不是 Idle 就直接返回 Offset.Zero);反过来回弹激活时下拉刷新也不接滚动量。所以两个手势不会打架。
  • 来源:整套来自 compose-miuix-ui(top.yukonga.miuix.kmp.utils),包名改成了 com.hualuo.compose.ui.overscroll,类名保持原样。SpringUtils.kt 里的 SpringMath / SpringEngine 都是 internal,不是公开 API,别直接引用;要用就通过上面这几个入口。