外观
过度滚动 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
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
nestedScrollToParent | Boolean | true | 是否把嵌套滚动事件继续派发给父级 |
isEnabled | () -> Boolean | { true } | 动态开关。给 lambda 而不是 Boolean |
kotlin
Modifier.overScrollVertical() // 竖轴、派发给父级
Modifier.overScrollVertical(nestedScrollToParent = false) // 自己吃掉,不往上传
Modifier.overScrollHorizontal(isEnabled = { listState.canScrollForward })Modifier.overScrollOutOfBound
上面两个都只是它的预设,完整参数是这三个:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
isVertical | Boolean | true | true 竖向、false 横向 |
nestedScrollToParent | Boolean | true | 同上的派发开关 |
isEnabled | () -> Boolean | { true } | 同上 |
LocalOverScrollState / OverScrollState
| 成员 | 类型 | 说明 |
|---|---|---|
LocalOverScrollState | ProvidableCompositionLocal<OverScrollState> | 页内共享的回弹状态;默认值是一个新实例(不 provide 也能跑,只是各读各的) |
OverScrollState.isOverScrollActive | Boolean | 当前是否正在回弹。只读,由效果内部写 |
MiuixOverscrollFactory / MiuixOverscrollEffect
| 名称 | 说明 |
|---|---|
MiuixOverscrollFactory | OverscrollFactory 的实现,由主题通过 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,别直接引用;要用就通过上面这几个入口。