
2026/6/30 · 8:11
UI 动效 006:Pull to Refresh,为什么往下拖一下就能刷新
这期拆解 Pull to Refresh / 下拉刷新:它适合会持续更新的列表,关键是边界检测、拖动阈值、刷新中状态和回位动画。文中用自制 GIF 与 JS 伪代码说明怎么把手势距离变成可控的刷新反馈。
下拉刷新看起来很简单:手指往下拖,顶部冒出一个小 spinner,松手后列表更新。它真正解决的问题不是「让刷新更酷」,而是给用户一个可逆的确认过程:轻轻拉一下只是预览,越过阈值再松手才算正式刷新。
Pull to Refresh 也常被写作 Swipe to Refresh。Material Design 把它定义为在列表、网格列表、卡片集合开头使用的手势,适合最近内容会持续更新的动态集合;Android Compose 的文档也把它描述为让用户在内容开头向下拖动以刷新数据的组件。12

它叫什么
| 名称 | 常见写法 | 更适合出现在哪里 |
|---|---|---|
| 下拉刷新 | Pull to Refresh | 中文产品说明、交互标注 |
| 滑动刷新 | Swipe to Refresh | Material Design 早期规范、Android 语境 |
| Refresh Indicator | RefreshIndicator / UIRefreshControl | Flutter、iOS 等组件命名 |
这三个说法指向同一类交互:用户在滚动内容的边界继续拖动,系统把拖动距离转成可见进度,达到阈值后触发刷新。
视觉上发生了什么
一个顺手的下拉刷新,通常有四个阶段。
- 边界检测:只有列表已经在顶部,继续向下拖才进入下拉刷新。否则这次手势应该先用于普通滚动。
- 位移映射:内容区域跟着手指向下移动,但位移会被压缩,常见做法是让 UI 只移动手指距离的一部分。
- 阈值触发:指示器必须越过阈值,松手才会刷新。Material Design 明确要求刷新指示器超过阈值后再触发,并用透明度、旋转速度和位移变化提示状态。1
- 刷新与回位:刷新开始后,指示器继续留在可见位置;数据完成后,列表和指示器再回到初始位置。Flutter 的
RefreshIndicator文档也采用类似流程:子滚动视图 overscroll 时淡入圆形进度,拖得足够远并结束滚动后调用onRefresh,Future 完成后指示器消失。3
这里最容易做错的是第三步。没有阈值,轻微误触会触发刷新;阈值太高,用户会觉得「明明已经拉了很远,怎么还不动」。所以这类动效的手感,不在 spinner 本身,而在拖动距离、阈值和释放后的回位节奏。
什么时候该用,什么时候别用
Material Design 的建议很直接:下拉刷新适合最近内容会从固定位置出现的动态集合,比如邮件、消息流、时间线或通知列表;它不适合导航抽屉、主屏小组件,也不适合地图这类可平移内容,因为用户很难判断刷新手势应该从哪里开始。1
可以用它的场景通常有两个共同点:
- 内容有明确的顶部或底部边界。
- 用户手动刷新后,有较大概率看到新内容。
如果刷新后大多数时候什么都不变,动效再顺也会变成「拉一下,空等一下」。这种界面更适合自动同步、显式刷新按钮,或者把最近更新时间直接写出来。
实现时,其实是在写一个小状态机
把下拉刷新拆开看,它不是一个单独动画,而是一组状态切换。
| 状态 | 进入条件 | UI 反馈 |
|---|---|---|
idle | 列表正常滚动 | 不显示指示器 |
pulling | 顶部继续下拉,但未到阈值 | 内容下移,spinner 透明度上升 |
armed | 位移超过阈值 | spinner 变为完整状态,提示松手可刷新 |
refreshing | 松手且已越过阈值 | 固定内容位置,执行数据请求 |
settling | 刷新完成或取消 | 内容和指示器回到 0 |
Web 端可以用 touch / pointer 事件自己写,也可以交给框架组件。下面是简化版伪代码,重点是「只在顶部拦截向下拖动」「用进度驱动 UI」「松手后按阈值决定是否刷新」。
const THRESHOLD = 80;
let startY = 0;
let pull = 0;
let state = "idle";
list.addEventListener("touchstart", (event) => {
if (list.scrollTop === 0) {
startY = event.touches[0].clientY;
state = "idle";
}
});
list.addEventListener("touchmove", (event) => {
const dy = event.touches[0].clientY - startY;
const atTop = list.scrollTop === 0;
if (!atTop || dy <= 0) return;
event.preventDefault();
pull = Math.min(dy * 0.55, THRESHOLD * 1.4);
const progress = Math.min(pull / THRESHOLD, 1);
state = progress >= 1 ? "armed" : "pulling";
surface.style.transform = `translateY(${pull}px)`;
indicator.style.opacity = progress;
indicator.style.transform = `rotate(${progress * 240}deg)`;
});
list.addEventListener("touchend", async () => {
if (state === "armed") {
state = "refreshing";
await loadLatestItems();
}
state = "settling";
surface.animate(
[{ transform: `translateY(${pull}px)` }, { transform: "translateY(0)" }],
{ duration: 240, easing: "cubic-bezier(.2,.8,.2,1)" }
);
pull = 0;
});在浏览器里做自定义下拉刷新,还要小心系统自带的 overscroll 行为。MDN 对
overscroll-behavior 的说明是:它控制浏览器到达滚动边界时做什么;contain 可以阻止相邻滚动区域的 scroll chaining,也会禁用浏览器原生的垂直下拉刷新手势。4html,
body {
overscroll-behavior-y: contain;
}这行不负责做动画,只是先把浏览器默认行为管住。真正的手感,还是由上面的位移压缩、阈值和回位动画决定。
各平台现成组件怎么想这件事
| 平台 | 现成入口 | 你主要调什么 |
|---|---|---|
| Android Compose | PullToRefreshBox | isRefreshing、onRefresh、indicator,自定义指示器时可用 state.distanceFraction 驱动缩放和透明度。2 |
| Flutter | RefreshIndicator | onRefresh 返回 Future;内容不够长也想触发时,可设置 AlwaysScrollableScrollPhysics;它只能用于垂直滚动视图。3 |
| iOS UIKit | UIRefreshControl | Apple 把它描述为可发起 scroll view 内容刷新的标准控件。5 |
| Web | 自定义手势 + CSS 边界控制 | 先处理滚动边界和默认 overscroll,再用 JS 状态机驱动 indicator。4 |
设计细节:别让 spinner 抢戏
下拉刷新的主角不是那个圈,而是用户手指和内容边界之间的关系。做设计稿或工程实现时,可以按这几个点检查:
- 指示器是否只在内容边界出现,不打断普通滚动。
- 松手前是否能看出「还差一点」和「已经可以刷新」。
- 刷新中是否保持可见反馈,不要让用户以为手势丢了。
- 刷新结束后是否回到原位,不要留下额外空白。
如果要做品牌化的自定义指示器,最好只换图形,不要改掉阈值逻辑。Android Compose 的自定义示例也保留了默认行为和阈值,只把图标、颜色、缩放和透明度换成自己的样式。2
这就是 Pull to Refresh 的核心:它把一个看不见的数据刷新动作,包装成一个有起点、有确认、有完成反馈的手势合同。用户不是在「拉一个 spinner」,而是在告诉系统:我已经到达列表边界,现在要重新拿一遍最新内容。

围绕这条内容继续补充观点或上下文。