Raft 设计系统

组件 · 反馈

PullToRefresh

在触屏上从列表顶部往下拉来重新加载,用于加载快照、不会自己更新的手机页面;从不用在由实时更新驱动的页面上。

实验性也叫 Pull to refresh, Pull-to-refresh, Swipe to refresh, Refresh control
import { PullToRefresh } from "@botiverse/raft-ui/wip";

人用手指把页面的列表从顶部往下拉时,下拉刷新会重新加载这个页面。把它用在打开时加载一份快照、不会自己更新的手机页面上,比如收藏。靠实时更新保持最新的页面从不放它(导航)。

pull-to-refresh/compositions/preview

它由两部分组成:一个 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 决定)。

何时使用

情况使用
打开时加载快照的手机页面:收藏、出现搜索结果之后的搜索结果、频道或消息列的文件、设置里的列表(邀请、加入链接、应用、账单)PullToRefresh
由实时更新驱动的页面:消息、消息列、私信、主页的会话列表、动态、任务、成员、计算机和单台计算机的页面、频道成员不用。页面自己保持最新,应用回到前台或重新连上时会追上
还没有显示结果的搜索不用。没有东西可刷新
鼠标或触控板不用这个组件:它只响应手指
首次加载失败错误状态里的重试(反馈)

不要在实时页面上放下拉刷新:一个要下拉才显示新内容的页面,等于告诉人它可能已经过时,于是人会把每个页面都拉一下才放心。实时页面必须不靠下拉就保持最新;做不到时,那是它的更新里要修的 bug,而不是让人去绕过的东西。

结构

  • usePullToRefresh(scrollRef, { onRefresh, disabled }):必需。作用在 scrollRef 指向的滚动容器上的手势。它返回这次下拉:它的状态(idle、pulling、refreshing)、距离、到阈值的进度,以及它采用哪种平台形态(default 或 android)。
  • PullToRefreshIndicator:必需。用 pull 接收这次下拉,用 label 接收播报文字。它是下拉在列表顶部拉开的那块空间,里面是 Spinner;在 Android 上则换成 Material 3 自己的指示器:一个 40px 的圆盘,里面有一段进度弧,在列表上方往下移动。

状态

pull-to-refresh/compositions/states

上面一行是默认形态,用 Spinner;下面一行是 Android 的形态,见在 Android 上。下面的状态说的是默认形态。

  • 下拉中: 加载指示器保持不动,随下拉渐显;每拉过阈值的四分之一(25px),它亮着的像素就顺时针走一步,所以它显示这时松手离刷新还有多近。到达阈值时,下拉就绪:加载指示器完全显示,最后一个像素亮起。
  • 刷新中: 加载指示器转动,指示器停在 60px,直到 onRefresh 返回的 promise 结束。
  • 失败: hook 只负责结束刷新。在 onRefresh 里,把内容留在屏幕上,显示一条 warning 轻提示,文案和首次加载失败时相同(Toast)。不要显示横幅:屏幕上的内容仍然有用,只是不是最新的。

行为

值默认值选项
橡皮筋:指示器移动手指移动距离的这个比例0.55
阈值:拉到这么远,下拉就绪,松手就刷新100pxthreshold
最远下拉距离140pxmaxDistance
刷新时的高度60pxholdDistance
复位:手指抬起或刷新结束后300ms,缓出
  • 它只响应手指。在精确指针(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 看起来像另一种控件。

pull-to-refresh/compositions/android

从左到右:下拉中、就绪、解除和刷新中。

  • 圆盘。 一个 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

实验性The pull's indicator. Render it as the scroller's first child: by default it is the space the pull opens, with the DS Spinner in it, so the content moves down with the pull; on Android it is Material 3's own indicator, a disc with a progress arc that travels down over the content. It holds a polite live region that announces `label` while refreshing.
Prop类型默认值
label (required)What a screen reader announces when the refresh starts, such as "Refreshing…". Product copy, so it comes from the consumer.string
pull (required)The result of `usePullToRefresh`.PullToRefresh

Also takes the attributes of its element.