外观
PullToRefresh — 下拉刷新
包住可滚动内容的下拉刷新容器。指示条与文案常驻在内容上方,下拉时从上面「长出来」,松手回弹或进入刷新。和过度滚动(overScroll*)共用一套弹簧阻尼,两者互相让路。
基本用法
kotlin
var isRefreshing by remember { mutableStateOf(false) }
PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.refresh() // 刷完自己置回 false
},
) {
LazyColumn { items(items) { … } }
}配合 ViewModel:
kotlin
val uiState by vm.uiState.collectAsState()
PullToRefresh(
isRefreshing = uiState.isRefreshing,
onRefresh = vm::refresh,
) {
LazyColumn(state = listState) { … }
}进页面就自动刷新(不需要手势):
kotlin
var isRefreshing by remember { mutableStateOf(true) }
// 只管把 isRefreshing 提起来,指示器会自己展开并停住
LaunchedEffect(Unit) { vm.refresh() }
PullToRefresh(isRefreshing = isRefreshing, onRefresh = { isRefreshing = true }) { … }自定义文案 / 触发深度:
kotlin
val ptrState = rememberPullToRefreshState(refreshThreshold = 0.4f) // 要拉得更深才触发
PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true },
pullToRefreshState = ptrState,
refreshTexts = listOf("下拉看看有没有新的", "松手", "马上就绪…", "好了"),
) { … }监听下拉深度(做视差、透明度联动):
kotlin
PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true },
onPullProgress = { progress -> headerAlpha = progress },
) { … }顶部还有固定标题栏时:
kotlin
PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true },
contentPadding = PaddingValues(top = 64.dp), // 指示条整体下移这么多
) { … }参数
PullToRefresh
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
isRefreshing | Boolean | — | 是否正在刷新。必须由调用方托管,而且是双向的(见「要点」) |
onRefresh | () -> Unit | — | 用户下拉超过阈值时触发。里面要自己把 isRefreshing 置 true,并负责最后置回 false |
modifier | Modifier | Modifier | 修饰符 |
pullToRefreshState | PullToRefreshState | rememberPullToRefreshState() | 状态对象(动画、阈值、进度) |
contentPadding | PaddingValues | PaddingValues(0.dp) | 内容内边距。它的 top 也被用来把指示条整体下移 |
color | Color | PullToRefreshDefaults.color | 指示条与文字颜色 |
refreshTexts | List<String> | 见 PullToRefreshDefaults | 四个状态各一句文案 |
refreshTextStyle | TextStyle | 14sp / Bold / 取 color | 文案样式 |
onPullProgress | ((Float) -> Unit)? | null | 观察 fullDragProgress。组合时先调一次,之后每次变化都调 |
content | @Composable () -> Unit | — | 内容。里面得是可滚动的容器 |
rememberPullToRefreshState
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
refreshThreshold | Float | 0.25f | 触发刷新所需的下拉进度(0~1,相对整个阻尼拖拽范围)。值越小越容易触发;0f 表示任何下拉都触发。会夹取到 0f..1f |
PullToRefreshDefaults
| 成员 | 值 |
|---|---|
color | Color.Gray |
refreshTexts | 继续下拉刷新 / 松开立即刷新 / 正在刷新… / 刷新完成 |
refreshTextStyle | 14.sp、FontWeight.Bold、颜色取 color |
PullToRefreshState 的公开成员
| 成员 | 类型 | 说明 |
|---|---|---|
refreshState | RefreshState | 当前视觉状态。只读 |
refreshThreshold | Float | 当前阈值。只读,通过 rememberPullToRefreshState 配置 |
dragOffset | Float | 当前拖拽偏移(px)。可读写 |
pullProgress | Float | 相对有效阈值的进度,0..1,到阈值后饱和在 1 |
fullDragProgress | Float | 相对整个阻尼范围的进度,0..1。越过阈值后还会继续涨 |
visualProgress | Float | 指示条自身的缩放进度,0..1。在两个阈值中较小的那个处到达 1 |
cachedNestedScrollConnection | NestedScrollConnection? | 内部缓存的嵌套滚动连接,一般不用碰 |
RefreshState
| 值 | 含义 |
|---|---|
Idle | 静息:指示条完全收起,头部高度 0 |
Pulling | 正常下拉中,还没到阈值 |
ThresholdReached | 已过阈值,松手就会刷新 |
Refreshing | 刷新中,指示条停在固定位置 |
RefreshComplete | 刷新完成,正在播完成动画 |
要点
isRefreshing是双向的,不只是「你有没有在刷」。 在Idle时把它提成true,指示器会以动画自行展开并停住 —— 这就是「进页面自动刷新」的实现方式,不需要手势。把它降回false才是结束刷新。onRefresh在「正在刷新」时不会被再次调用。 如果指示器落位时isRefreshing已经是true,这次手势会加入正在进行的刷新,而不是再触发一次。所以onRefresh里「提起来 + 发请求」是安全的,不用担心重复请求。onRefresh里必须自己把isRefreshing置true。 组件不会替你改这个状态 —— 忘了置的话,指示器落位后又立刻收到「已经刷完」,直接播完成动画,看起来就是「压根没刷新」。- 刷完必须自己置回
false,没有任何超时兜底。 - 下拉只在「按下并拖动」的手势里生效。 鼠标滚轮完全不参与(滚轮没有「松手」信号);鼠标按下也不能拖动可滚动容器,所以桌面端下拉刷新只能靠触摸屏或触控板 pan。
content里必须放可滚动容器。 它靠nestedScroll拦滚动事件,放个静态Column是拉不动的。- 指示条与文案是常驻的两行,头部高度按两行算过。 不要把高度写死回 56dp —— 那样文案会被压成 0 高,看起来就是「文案不见了」。
contentPadding.top会同时把指示条往下推。 顶部还有固定标题栏时靠它对齐;如果只是想让内容开头多留点空间,改用内容里自己的padding,否则指示条也会跟着下移。refreshThreshold是比例不是像素。 而那个阻尼范围本身是按屏幕高度算出来的,所以同样的0.4f在不同设备上对应的手指行程并不相同 —— 视觉上大致一致。
说明
- 物理学和过度滚动同源。 阻尼用
SpringMath的x - x² + x³/3曲线,回弹用同一个SpringEngine(临界阻尼、周期 0.4s / 高速 0.55s)。所以「下拉刷新」和「滑到顶继续拽」的手感是同一条曲线,不是两套。 - 两者会互相让路。 过度滚动激活且刷新状态是
Idle时,下拉刷新直接返回Offset.Zero,不接滚动量;反过来,刷新进行中(RefreshState不是Idle)时过度滚动的竖向位移也让位。所以两个手势不会打架、不会叠加出双倍位移。 - 指示条是内容上方真实的一行,不是浮层。 布局是
Column { RefreshHeader; content }—— 下拉时内容是被推下去的,而不是在上面蒙一层东西。所以Idle时头部高度为0.dp,不占任何空间。 RefreshState与isRefreshing是两套状态,会双向对账。refreshState管视觉,isRefreshing管业务。进入Refreshing时会重新采样一次isRefreshing(refreshState是那个LaunchedEffect的 key 之一),目的是让「同一帧内 true→false 的脉冲」不能把状态锁死。- 有一个 400ms 的兜底对账(watchdog)。 不在手势里、不在刷新里、也没有任何弹簧 / 完成动画在跑的时候,指示器必须收起 —— 否则会出现「手势结束事件没送到」「回弹弹簧被打断」「完成动画协程被取消」这几种情况,顶上的横条一直挂在列表上,只能靠再滑一下推回去。400ms 是留给正常动画的(回弹与完成动画都在 200ms 量级),正常收尾绝不会被它打断。
visualProgress在两个阈值里取小的那个:指示条的放大在较早的那个阈值处完成,这样「进入ThresholdReached」永远与「指示条已经完整放大」同时发生 —— 不会出现「已经提示可以松手了,圈还是缩着的」。onPullProgress走snapshotFlow按帧合并,不是每次状态写入都回调。所以拿它驱动外部动画不会造成每帧多次重组。