# PullToRefresh

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

Source: https://raft-design.botiverse.build/zh/components/pull-to-refresh. Translated from https://raft-design.botiverse.build/components/pull-to-refresh.md; where the two differ, the English page is right.

人用手指把页面的列表从顶部往下拉时，下拉刷新会重新加载这个页面。把它用在打开时加载一份快照、不会自己更新的手机页面上，比如收藏。靠实时更新保持最新的页面从不放它（[导航](https://raft-design.botiverse.build/zh/patterns/navigation.md#refreshing)）。

```tsx
import { useCallback, useRef, useState } from "react";
import {
  Avatar,
  AvatarFallback,
  ListRow,
  ListRowBody,
  ListRowBottom,
  ListRowDescription,
  ListRowMeta,
  ListRowTitle,
  ListRowTop,
} from "@botiverse/raft-ui";
import { PullToRefreshIndicator, usePullToRefresh } from "@botiverse/raft-ui/wip";

export function PullToRefreshPreviewContent() {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [savedElsewhere, setSavedElsewhere] = useState(false);
  const onRefresh = useCallback(async () => {
    await new Promise((resolve) => setTimeout(resolve, 1200));
    setSavedElsewhere(true);
  }, []);
  const pull = usePullToRefresh(scrollRef, { onRefresh });

  return (
    <div ref={scrollRef} className="h-80 w-full max-w-sm overflow-y-auto">
      <PullToRefreshIndicator pull={pull} label="Refreshing…" />
      {savedElsewhere && (
        <ListRow interactive={false}>
          <Avatar size="md" type="human">
            <AvatarFallback>M</AvatarFallback>
          </Avatar>
          <ListRowBody>
            <ListRowTop>
              <ListRowTitle>Martin</ListRowTitle>
              <ListRowMeta>10:52</ListRowMeta>
            </ListRowTop>
            <ListRowBottom>
              <ListRowDescription>The #launch-week checklist is final.</ListRowDescription>
            </ListRowBottom>
          </ListRowBody>
        </ListRow>
      )}
      <ListRow interactive={false}>
        <Avatar size="md" type="agent">
          <AvatarFallback>B</AvatarFallback>
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Bugen</ListRowTitle>
            <ListRowMeta>10:40</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Release notes for #25 are in the doc.</ListRowDescription>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
      <ListRow interactive={false}>
        <Avatar size="md" type="human">
          <AvatarFallback>M</AvatarFallback>
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Martin</ListRowTitle>
            <ListRowMeta>09:12</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Launch moves to Thursday.</ListRowDescription>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
      <ListRow interactive={false}>
        <Avatar size="md" type="agent">
          <AvatarFallback>W</AvatarFallback>
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Wug</ListRowTitle>
            <ListRowMeta>Mon</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Benchmarks attached, p95 is down 18%.</ListRowDescription>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
    </div>
  );
}
```

它由两部分组成：一个 hook `usePullToRefresh`，挂在页面已有的滚动容器上；一个部件 `PullToRefreshIndicator`，渲染为这个容器的第一个子元素。浏览器在滚动容器里、在安装后的网页应用（`display: standalone`）里都不画自己的下拉刷新，所以手机网页画这一个。iOS 和 Android 应用用它们自己的控件，这个组件调得和它们手感一样：数值取自 iOS 应用（mobile #3553），在 Android 上采用 Material 3 的形态（[在 Android 上](#on-android)）。在 Android 上，它的指示器是 Android 自己的，即小圆盘上 Material 3 的进度弧，这样在 Android 的 Chrome 里下拉，看起来和在任何 Android 应用里一样（由 stdrc 于 2026-10-23 决定）。在其他所有地方，包括 iOS，指示器都是设计系统的加载图标，即 [Spinner](https://raft-design.botiverse.build/zh/components/spinner.md)，从不用平台自己的加载指示器（artin 决定）。

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

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

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

## 状态
```tsx
import { PullToRefreshIndicator } from "@botiverse/raft-ui/wip";
import type { PullToRefresh, PullToRefreshPlatform } from "@botiverse/raft-ui/wip";

// Halfway to the 100px threshold, and held at 60px while refreshing.
const pulling = (platform: PullToRefreshPlatform): PullToRefresh => ({
  state: "pulling",
  distance: 50,
  progress: 0.5,
  platform,
});
const refreshing = (platform: PullToRefreshPlatform): PullToRefresh => ({
  state: "refreshing",
  distance: 60,
  progress: 1,
  platform,
});

export function PullToRefreshStatesContent() {
  return (
    <div className="grid w-full max-w-md grid-cols-2 gap-4">
      {(["default", "android"] as const).map((platform) =>
        [pulling(platform), refreshing(platform)].map((pull) => (
          <div
            key={`${platform}-${pull.state}`}
            className="h-24 overflow-hidden rounded-md border border-line-hairline"
          >
            <PullToRefreshIndicator pull={pull} label="Refreshing…" />
          </div>
        )),
      )}
    </div>
  );
}
```

上面一行是默认形态，用 Spinner；下面一行是 Android 的形态，见[在 Android 上](#on-android)。下面的状态说的是默认形态。

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

## 行为
| 值 | 默认值 | 选项 |
| --- | --- | --- |
| 橡皮筋：指示器移动手指移动距离的这个比例 | 0.55 | |
| 阈值：拉到这么远，下拉就绪，松手就刷新 | 100px | `threshold` |
| 最远下拉距离 | 140px | `maxDistance` |
| 刷新时的高度 | 60px | `holdDistance` |
| 复位：手指抬起或刷新结束后 | 300ms，缓出 | |

- 它只响应手指。在精确指针（`pointer: fine`）上，hook 不添加任何监听器，指示器保持 0px。
- 只有滚动容器在顶部、且只有一根手指时，下拉才会开始。先往侧面或往上移动，是滑动或滚动，不是下拉。
- 在手指向下移动 6px 之前，页面照常滚动；从那以后，hook 在这次触摸中阻止浏览器滚动或回弹。
- 从文本框、`select`、可编辑元素或 `[data-pull-to-refresh-ignore]` 里面的任何东西上开始的触摸，从不触发下拉。把这个属性加在拖动手柄上，以及其他随手指移动的东西上，比如滑块或轮播。
- 手指在下拉前静止按住 500ms 就是长按：下拉随即放弃，让菜单或拖动可以开始（[状态](https://raft-design.botiverse.build/zh/foundations/states.md#dragging-on-a-touch-screen)）。`contextmenu` 或 `dragstart` 事件，或者已经被另一个处理函数接管的触摸移动，也会结束下拉。
- 开启时，滚动容器带上 `overscroll-behavior-y: contain`，这样浏览器标签页不会同时重新加载整个页面。关闭时，hook 会把原来的值放回去。
- 就绪的下拉在就绪期间一直占住这次触摸：页面不会在它下面滚动。
- 刷新过程中再下拉，什么都不会发生。
- 开启 `prefers-reduced-motion` 时，指示器复位没有过渡，加载指示器也不转动；在 Android 上，刷新时弧线保持不动，圆盘直接消失（[动效](https://raft-design.botiverse.build/zh/foundations/motion.md)）。

`disabled` 关闭它，比如首次加载完成之前，或搜索为空时。不要根据刷新本身来设置它：刷新到一半关闭它，会让指示器在 promise 结束前就消失。

### 为什么是这些数值
- **它们是 iOS 应用的数值。** 移动团队为 iOS 应用定下了它们（mobile #3553），产品负责人批准了，所以在应用里和在手机网页上，下拉的手感一样。
- **0.55** 是 iOS 自己的滚动视图的橡皮筋系数：列表落后于手指，所以下拉读起来像有阻力。到达阈值大约要手指移动 182px，所以刷新需要有意地拉一下。
- **阈值会锁定，Android 除外。** 下拉一旦超过 100px，就保持就绪直到手指抬起，即使手指先往回移了也一样；这时松手就会刷新，和 iOS 的 `UIRefreshControl` 一样。拉过了那个点又稍微回收的人，仍然能得到他们要的刷新。只有取消（浏览器接管了这次触摸、第二根手指）才会让就绪的下拉放弃。Android 不锁定：Material 3 允许把一次下拉收回；iOS 一旦就绪就不再收回（[在 Android 上](#on-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 看起来像另一种控件。

```tsx
import { PullToRefreshIndicator } from "@botiverse/raft-ui/wip";
import type { PullToRefresh } from "@botiverse/raft-ui/wip";

// Android's own indicator through a pull, on the 100px threshold.
// On the way down: 75px, not armed yet.
const pulling: PullToRefresh = {
  state: "pulling",
  distance: 75,
  progress: 0.75,
  platform: "android",
};
// Past the threshold: armed.
const armed: PullToRefresh = { state: "pulling", distance: 120, progress: 1, platform: "android" };
// Back above the threshold before release: disarmed.
const disarmed: PullToRefresh = {
  state: "pulling",
  distance: 90,
  progress: 0.9,
  platform: "android",
};
// Released while armed: held at 60px until the refresh settles.
const refreshing: PullToRefresh = {
  state: "refreshing",
  distance: 60,
  progress: 1,
  platform: "android",
};

export function PullToRefreshAndroidContent() {
  return (
    <div className="grid w-full max-w-lg grid-cols-2 gap-4 sm:grid-cols-4">
      <div className="h-36 overflow-hidden rounded-md border border-line-hairline">
        <PullToRefreshIndicator pull={pulling} label="Refreshing…" />
      </div>
      <div className="h-36 overflow-hidden rounded-md border border-line-hairline">
        <PullToRefreshIndicator pull={armed} label="Refreshing…" />
      </div>
      <div className="h-36 overflow-hidden rounded-md border border-line-hairline">
        <PullToRefreshIndicator pull={disarmed} label="Refreshing…" />
      </div>
      <div className="h-36 overflow-hidden rounded-md border border-line-hairline">
        <PullToRefreshIndicator pull={refreshing} label="Refreshing…" />
      </div>
    </div>
  );
}
```

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

- **圆盘。** 一个 40px 的圆，底色是 `layer-popover`，浅色模式下是白色，深色模式下是凸起的深色表面，带 `elevation-float` 阴影：它短暂地浮在列表之上，像弹出层一样（[层级](https://raft-design.botiverse.build/zh/foundations/elevation.md)）。它没有轮廓，因为 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 里，青色表示进度（[颜色](https://raft-design.botiverse.build/zh/foundations/color.md#meanings-of-color)：进度条、进行中），而 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 的弧线，对辅助技术隐藏。
- 没有手指就无法下拉，对这些页面来说这没问题：每个页面打开时都会重新加载，读屏软件用户也可以这样做。不要为他们在手机上加刷新按钮（[导航](https://raft-design.botiverse.build/zh/patterns/navigation.md#refreshing)）。

## 规则
- 把指示器放在 `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 | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | What a screen reader announces when the refresh starts, such as "Refreshing…". Product copy, so it comes from the consumer. |
| `pull` (required) | `PullToRefresh` |  | The result of `usePullToRefresh`. |

Also takes the attributes of its element.
