组件 · 反馈
PullToRefresh
在触屏上从列表顶部往下拉来重新加载,用于加载快照、不会自己更新的手机页面;从不用在由实时更新驱动的页面上。
import { PullToRefresh } from "@botiverse/raft-ui/wip";人用手指把页面的列表从顶部往下拉时,下拉刷新会重新加载这个页面。把它用在打开时加载一份快照、不会自己更新的手机页面上,比如收藏。靠实时更新保持最新的页面从不放它(导航)。
它由两部分组成:一个 hook usePullToRefresh,挂在页面已有的滚动容器上;一个部件 PullToRefreshIndicator,渲染为这个容器的第一个子元素。浏览器在滚动容器里、在安装后的网页应用(display: standalone)里都不画自己的下拉刷新,所以手机网页画这一个。iOS 和 Android 应用用它们自己的控件,这个组件调得和它们手感一样:数值取自 iOS 应用(mobile #3553),在 Android 上采用 Material 3 的形态(在 Android 上)。在 Android 上,它的指示器是 Android 自己的,即小圆盘上 Material 3 的进度弧,这样在 Android 的 Chrome 里下拉,看起来和在任何 Android 应用里一样(由 stdrc 于 2026-10-23 决定)。在其他所有地方,包括 iOS,指示器都是设计系统的加载图标,即 Spinner,从不用平台自己的加载指示器(artin 决定)。
何时使用
不要在实时页面上放下拉刷新:一个要下拉才显示新内容的页面,等于告诉人它可能已经过时,于是人会把每个页面都拉一下才放心。实时页面必须不靠下拉就保持最新;做不到时,那是它的更新里要修的 bug,而不是让人去绕过的东西。
结构
usePullToRefresh(scrollRef, { onRefresh, disabled }):必需。作用在scrollRef指向的滚动容器上的手势。它返回这次下拉:它的状态(idle、pulling、refreshing)、距离、到阈值的进度,以及它采用哪种平台形态(default或android)。PullToRefreshIndicator:必需。用pull接收这次下拉,用label接收播报文字。它是下拉在列表顶部拉开的那块空间,里面是 Spinner;在 Android 上则换成 Material 3 自己的指示器:一个 40px 的圆盘,里面有一段进度弧,在列表上方往下移动。
状态
上面一行是默认形态,用 Spinner;下面一行是 Android 的形态,见在 Android 上。下面的状态说的是默认形态。
- 下拉中: 加载指示器保持不动,随下拉渐显;每拉过阈值的四分之一(25px),它亮着的像素就顺时针走一步,所以它显示这时松手离刷新还有多近。到达阈值时,下拉就绪:加载指示器完全显示,最后一个像素亮起。
- 刷新中: 加载指示器转动,指示器停在 60px,直到
onRefresh返回的 promise 结束。 - 失败: hook 只负责结束刷新。在
onRefresh里,把内容留在屏幕上,显示一条warning轻提示,文案和首次加载失败时相同(Toast)。不要显示横幅:屏幕上的内容仍然有用,只是不是最新的。
行为
- 它只响应手指。在精确指针(
pointer: fine)上,hook 不添加任何监听器,指示器保持 0px。 - 只有滚动容器在顶部、且只有一根手指时,下拉才会开始。先往侧面或往上移动,是滑动或滚动,不是下拉。
- 在手指向下移动 6px 之前,页面照常滚动;从那以后,hook 在这次触摸中阻止浏览器滚动或回弹。
- 从文本框、
select、可编辑元素或[data-pull-to-refresh-ignore]里面的任何东西上开始的触摸,从不触发下拉。把这个属性加在拖动手柄上,以及其他随手指移动的东西上,比如滑块或轮播。 - 手指在下拉前静止按住 500ms 就是长按:下拉随即放弃,让菜单或拖动可以开始(状态)。
contextmenu或dragstart事件,或者已经被另一个处理函数接管的触摸移动,也会结束下拉。 - 开启时,滚动容器带上
overscroll-behavior-y: contain,这样浏览器标签页不会同时重新加载整个页面。关闭时,hook 会把原来的值放回去。 - 就绪的下拉在就绪期间一直占住这次触摸:页面不会在它下面滚动。
- 刷新过程中再下拉,什么都不会发生。
- 开启
prefers-reduced-motion时,指示器复位没有过渡,加载指示器也不转动;在 Android 上,刷新时弧线保持不动,圆盘直接消失(动效)。
disabled 关闭它,比如首次加载完成之前,或搜索为空时。不要根据刷新本身来设置它:刷新到一半关闭它,会让指示器在 promise 结束前就消失。
为什么是这些数值
- 它们是 iOS 应用的数值。 移动团队为 iOS 应用定下了它们(mobile #3553),产品负责人批准了,所以在应用里和在手机网页上,下拉的手感一样。
- 0.55 是 iOS 自己的滚动视图的橡皮筋系数:列表落后于手指,所以下拉读起来像有阻力。到达阈值大约要手指移动 182px,所以刷新需要有意地拉一下。
- 阈值会锁定,Android 除外。 下拉一旦超过 100px,就保持就绪直到手指抬起,即使手指先往回移了也一样;这时松手就会刷新,和 iOS 的
UIRefreshControl一样。拉过了那个点又稍微回收的人,仍然能得到他们要的刷新。只有取消(浏览器接管了这次触摸、第二根手指)才会让就绪的下拉放弃。Android 不锁定:Material 3 允许把一次下拉收回;iOS 一旦就绪就不再收回(在 Android 上)。 - 140px 是上限:超过阈值 40px,表明下拉已就绪、仍在进行;在那里硬性停住,让长距离拖动不会把列表推出小手机的屏幕。
- 刷新时的 60px 是 iOS 应用的高度。它低于阈值,所以松手后指示器会往上落回一些,把下拉占用的空间还给列表一部分。
- 复位耗时 300ms,用
ease-slide:一条从不越过终点的缓出曲线。指示器停在它要去的地方,没有过冲也没有回弹,因为回弹看起来像又拉了一次。开启prefers-reduced-motion时,它立即复位。
在 Android 上
在 Android 上(按 user agent 判断),下拉遵循 Material 3 的下拉刷新,指示器也是 Android 自己的,所以它看起来和手机上的其他部分一样。由 stdrc 于 2026-10-23 决定:在 Android 网页上,指示器用 Material 的,这只对 Android 推翻了之前「所有平台都用 Spinner」的规则;iOS 和其他所有平台仍然用 Spinner。下拉是系统手势,在 Android 上人们靠 Material 的弧线认出它:Chrome 为页面画它,每个 Android 应用为列表画它。在那里,Spinner 看起来像另一种控件。
从左到右:下拉中、就绪、解除和刷新中。
- 圆盘。 一个 40px 的圆,底色是
layer-popover,浅色模式下是白色,深色模式下是凸起的深色表面,带elevation-float阴影:它短暂地浮在列表之上,像弹出层一样(层级)。它没有轮廓,因为 Material 的也没有:阴影让它在浅色页面上显出来,在深色模式下凸起的表面比画布更亮。它在列表上方往下移动,列表保持不动:内容没有橡皮筋拉伸。它仍然按手指移动距离的 0.55 移动。 - 下拉中。 圆盘里一段 2.5px 粗、半径 7.5px 的弧线随下拉增长,和 Material 画的一样:阈值的前 40% 什么都没有,然后到阈值时增长到整圆的 0.8,增长时稍微转动,末端带一个箭头。下拉就绪之前,弧线是 30% 不透明度。
- 就绪。 超过阈值后,弧线完全不透明,手机轻震一次(
navigator.vibrate(10)),前提是浏览器支持。其他地方不振动。 - 解除。 没有锁定:Material 3 允许把一次下拉收回;iOS 一旦就绪就不再收回。松手前往回移到阈值以内,下拉就解除:弧线回到 30% 不透明度,箭头留着,这时松手不会刷新。再次越过阈值会再次就绪,并再轻震一次。
- 刷新中。 圆盘停在 60px,弧线播放 Material 的不确定进度动画:每 1.4s 转一圈,同时伸长又缩短。
- 结束。 刷新结束时,圆盘在它停留的位置缩小到消失,耗时 150ms,和 Material 的一样。在阈值前松手的下拉则往上滑回,用通常的 300ms 复位。
- 弧线是
info-strong。 Material 用主题的主色画它。在 Raft 里,青色表示进度(颜色:进度条、进行中),而 Raft 的主色黄色表示「你」,在白色上只有 1.3:1。弧线是一条没有墨色边的细线,所以用青色的 strong 档:在白色圆盘上 11:1,在深色圆盘上 8:1;而info本身只有 1.9:1,低于图形需要的 3:1。 - 减弱动态效果。 开启
prefers-reduced-motion时,刷新期间弧线静止在整圆的四分之三,圆盘的移动和消失都没有过渡。
其他都和默认形态相同:数值和复位。platform 可以覆盖检测到的平台,比如在预览里。其他所有触屏,包括 iOS,都用默认形态:内容随下拉往下移,由 Spinner 显示这次下拉。
无障碍
- 指示器里有一个 polite 实时区域(
role="status"),刷新开始时播报label。传入产品自己的文案,比如 "Refreshing…":raft-ui 不自带任何文案。加载指示器,或 Android 的弧线,对辅助技术隐藏。 - 没有手指就无法下拉,对这些页面来说这没问题:每个页面打开时都会重新加载,读屏软件用户也可以这样做。不要为他们在手机上加刷新按钮(导航)。
规则
- 把指示器放在
scrollRef指向的元素里,作为第一个子元素,这样内容会随下拉往下移。 - 每个页面用一个,放在页面的主列表上。嵌套的滚动容器,比如横向一排标签片,加上
data-pull-to-refresh-ignore。 - 让
onRefresh返回一个在新内容出现在屏幕上时才结束的 promise,而不是请求发出时就结束:指示器会一直留到那时。
API
PullToRefreshIndicator
| Prop | 类型 | 默认值 |
|---|---|---|
| What a screen reader announces when the refresh starts, such as "Refreshing…". Product copy, so it comes from the consumer. | string | |
| The result of `usePullToRefresh`. | PullToRefresh |
Also takes the attributes of its element.