Skip to content

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 ​

参数类型默认值说明
isRefreshingBoolean—是否正在刷新。必须由调用方托管,而且是双向的(见「要点」)
onRefresh() -> Unit—用户下拉超过阈值时触发。里面要自己把 isRefreshing 置 true,并负责最后置回 false
modifierModifierModifier修饰符
pullToRefreshStatePullToRefreshStaterememberPullToRefreshState()状态对象(动画、阈值、进度)
contentPaddingPaddingValuesPaddingValues(0.dp)内容内边距。它的 top 也被用来把指示条整体下移
colorColorPullToRefreshDefaults.color指示条与文字颜色
refreshTextsList<String>见 PullToRefreshDefaults四个状态各一句文案
refreshTextStyleTextStyle14sp / Bold / 取 color文案样式
onPullProgress((Float) -> Unit)?null观察 fullDragProgress。组合时先调一次,之后每次变化都调
content@Composable () -> Unit—内容。里面得是可滚动的容器

rememberPullToRefreshState ​

参数类型默认值说明
refreshThresholdFloat0.25f触发刷新所需的下拉进度(0~1,相对整个阻尼拖拽范围)。值越小越容易触发;0f 表示任何下拉都触发。会夹取到 0f..1f

PullToRefreshDefaults ​

成员值
colorColor.Gray
refreshTexts继续下拉刷新 / 松开立即刷新 / 正在刷新… / 刷新完成
refreshTextStyle14.sp、FontWeight.Bold、颜色取 color

PullToRefreshState 的公开成员 ​

成员类型说明
refreshStateRefreshState当前视觉状态。只读
refreshThresholdFloat当前阈值。只读,通过 rememberPullToRefreshState 配置
dragOffsetFloat当前拖拽偏移(px)。可读写
pullProgressFloat相对有效阈值的进度,0..1,到阈值后饱和在 1
fullDragProgressFloat相对整个阻尼范围的进度,0..1。越过阈值后还会继续涨
visualProgressFloat指示条自身的缩放进度,0..1。在两个阈值中较小的那个处到达 1
cachedNestedScrollConnectionNestedScrollConnection?内部缓存的嵌套滚动连接,一般不用碰

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 按帧合并,不是每次状态写入都回调。所以拿它驱动外部动画不会造成每帧多次重组。