移动端导航下拉刷新

下拉刷新PullRefresh

列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口

反馈 规格 4 · 下拉刷新 PullRefresh 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">

<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/pullrefresh.css">

<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->

演示

每个演示都是真实渲染:预览帧加载 frameworks-mobile/PullRefresh.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。

01 组件类型

下拉中state=pull

未达阈值:指示器随位移旋转,提示「下拉即可刷新」。

查看代码(演示页原文 · 14 行)
frameworks-mobile/PullRefresh.html · pull
<section class="demo-block" data-demo="pull">
<p class="demo-label">下拉中(state=pull,未达阈值 60px)</p>
<div class="demo-frame" data-assert="pullrefresh-pull">
  <div class="kole-m-pullrefresh is-pulling" style="--kole-m-pullrefresh-offset: 36px">
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">下拉即可刷新</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
    </div>
  </div>
</div>
</section>
已达阈值state=ready

达到 60px 提示改为「松开立即刷新」,松手即进入刷新。

查看代码(演示页原文 · 14 行)
frameworks-mobile/PullRefresh.html · ready
<section class="demo-block" data-demo="ready">
<p class="demo-label">已达阈值(state=ready,松手立即刷新)</p>
<div class="demo-frame" data-assert="pullrefresh-ready">
  <div class="kole-m-pullrefresh is-ready" style="--kole-m-pullrefresh-offset: 60px">
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">松开立即刷新</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
    </div>
  </div>
</div>
</section>
刷新中state=refreshing

指示器持续旋转、下拉不回弹;期间再次下拉不重复触发。

查看代码(演示页原文 · 15 行)
frameworks-mobile/PullRefresh.html · refreshing
<section class="demo-block" data-demo="refreshing">
<p class="demo-label">刷新中(state=refreshing,在框内向下拖动触发)</p>
<div class="demo-frame" data-assert="pullrefresh-refreshing">
  <div class="kole-m-pullrefresh" id="pr-gesture" data-behavior="pull-triggers:#pr-gesture|is-refreshing">
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">下拉即可刷新</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
      <button class="kole-m-pullrefresh__fallback" type="button" id="pr-refresh">刷新列表</button>
    </div>
  </div>
</div>
</section>
完成提示state=done

完成态短暂停留后复位(停留时长规格未定,见「规格未定」)。

查看代码(演示页原文 · 15 行)
frameworks-mobile/PullRefresh.html · done
<section class="demo-block" data-demo="done">
<p class="demo-label">完成提示(state=done)</p>
<div class="demo-frame" data-assert="pullrefresh-done">
  <div class="kole-m-pullrefresh is-done" style="--kole-m-pullrefresh-offset: 60px">
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">刷新完成</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <ul class="demo-list"><li>订单 20260920-003(新)</li><li>订单 20260920-001</li></ul>
    </div>
  </div>
</div>
</div>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
thresholdnumber60触发阈值:浏览器端(css/h5/react/vue2/vue3)单位为 px,默认 60;uni-app 端单位为 rpx,默认 120(≈ 60px @375pt)N
refreshingbooleanfalse刷新中状态;由业务侧在 refresh 事件后置位(规格 §4.4)N
donebooleanfalse完成提示状态(规格 §4.4)N
fallbackLabelstring'刷新列表'非手势等价入口的文案(规格 §4.6)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
refresh—松手达到阈值时触发(手动点等价入口同样触发)

插槽

名称说明
default列表内容;组件在外层包裹手势与指示区(规格 §4.2)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-pullrefresh-threshold60px组件内部默认值,可在业务侧覆盖
--kole-m-pullrefresh-offset0px组件内部默认值,可在业务侧覆盖

何时使用

  • 列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口
  • 手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑
  • 达到阈值后松手进入 refreshing;未达阈值松手回弹
  • 刷新期间再次下拉不重复触发
  • 指示区 role=status + aria-live=polite,状态文字变化被读屏播报
  • 需保留一个非手势的等价入口(如列表底部的刷新按钮)

交互与触控

  • 手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑
  • 达到阈值后松手进入 refreshing;未达阈值松手回弹
  • 刷新期间再次下拉不重复触发

无障碍

  • 指示区 role=status + aria-live=polite,状态文字变化被读屏播报
  • 需保留一个非手势的等价入口(如列表底部的刷新按钮)

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
滑动单元格SwipeCell同属手势交互:下拉是纵向、滑动是横向,两者靠方向判定让位
顶部导航栏NavBar刷新常与顶栏配合(刷新后更新标题或角标)

规格未定 / 禁止发明

类别条目
禁止发明惯性与阻尼曲线
禁止发明与页面整体下拉(浏览器级)的竞争规则
规格未定刷新超时的提示形式
规格未定完成提示的停留时长

结构(anatomy)

字段说明
viewport包裹滚动内容的容器,负责手势
indicator下拉指示区,含箭头或旋转图标与状态文字
content业务内容

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
statepull / ready / refreshing / done
pull .is-pulling
ready .is-ready
refreshing .is-refreshing
done .is-done
threshold60
60 --kole-m-pullrefresh-threshold

代表变体

变体标签
state=pull · threshold=60下拉中
state=ready · threshold=60已达阈值
state=refreshing · threshold=60刷新中
state=done · threshold=60完成提示

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-duration-refresh --kole-m-font-size-label --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-bg --kole-color-card-bg --kole-color-focus-ring --kole-color-success --kole-color-text-body --kole-color-text-secondary --kole-ease-out --kole-font-family --kole-icon-size-20 --kole-space-8 --kole-m-pullrefresh-offset --kole-m-pullrefresh-threshold

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/PullRefresh.css · 纯样式(CSS) · 100 行
frameworks-mobile/PullRefresh.css
/* Kole UI Mobile · PullRefresh 样式 — 对齐移动端规格 §4
   下拉刷新:Pointer Events 手势,位移以纵向为主;阈值默认 60px;
   指示区 role=status + aria-live=polite,状态文字变化被读屏播报。 */

.kole-m-pullrefresh {
  /* 组件级变量:阈值与实时位移(业务侧可覆盖阈值) */
  --kole-m-pullrefresh-threshold: 60px;
  --kole-m-pullrefresh-offset: 0px;
  position: relative;
  overflow: hidden;
  font-family: var(--kole-font-family);
  color: var(--kole-color-text-body);
}

.kole-m-pullrefresh__indicator {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--kole-space-8);
  height: var(--kole-m-pullrefresh-offset);
  overflow: hidden;
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
  transition: height var(--kole-m-duration-refresh) var(--kole-ease-out);
}

/* 刷新中:指示区固定为阈值高度,旋转图标常显 */
.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__indicator {
  height: var(--kole-m-pullrefresh-threshold);
}

/* 完成提示:短暂显示后由业务侧复位 */
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__indicator {
  height: var(--kole-m-pullrefresh-threshold);
  color: var(--kole-color-success);
}

.kole-m-pullrefresh__spinner {
  flex: 0 0 auto;
  width: var(--kole-icon-size-20);
  height: var(--kole-icon-size-20);
  box-sizing: border-box;
  border: 2px solid var(--kole-color-border);
  border-top-color: var(--kole-color-brand);
  border-radius: 50%;
}

.kole-m-pullrefresh.is-pulling .kole-m-pullrefresh__spinner {
  transform: rotate(calc(var(--kole-m-pullrefresh-offset) * 3));
}

.kole-m-pullrefresh.is-ready .kole-m-pullrefresh__spinner,
.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__spinner,
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__spinner {
  animation: kole-m-pullrefresh-spin 800ms linear infinite;
}

.kole-m-pullrefresh.is-ready .kole-m-pullrefresh__spinner {
  border-top-color: var(--kole-color-success);
}

.kole-m-pullrefresh.is-done .kole-m-pullrefresh__spinner {
  border-color: var(--kole-color-success);
  animation: none;
}

@keyframes kole-m-pullrefresh-spin {
  to { transform: rotate(360deg); }
}

.kole-m-pullrefresh__content {
  transform: translateY(var(--kole-m-pullrefresh-offset));
  transition: transform var(--kole-m-duration-refresh) var(--kole-ease-out);
  /* 纵向滚动交给浏览器,横向手势不被本组件截获 */
  touch-action: pan-y;
}

/* 非手势的等价入口(规格 §4.6):手势不可用时仍能刷新 */
.kole-m-pullrefresh__fallback {
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: var(--kole-m-touch-target);
  border: 0;
  border-top: 1px solid var(--kole-color-border);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-brand);
  font-family: inherit;
  font-size: var(--kole-m-font-size-label);
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-pullrefresh__fallback:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: 2px;
}

.kole-m-pullrefresh__fallback:active { background: var(--kole-color-brand-bg); }
frameworks-mobile/PullRefresh.html · H5 原生(无框架) · 172 行
frameworks-mobile/PullRefresh.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · PullRefresh(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="PullRefresh.css">
<style>
  body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
  .demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
  .demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
  .demo-frame { position: relative; max-width: 375px; margin: 0 auto; height: 200px;
    overflow: hidden; background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  .demo-list { margin: 0; padding: 0; list-style: none; }
  .demo-list li { padding: var(--kole-space-12) var(--kole-m-gutter); border-bottom: 1px solid var(--kole-color-border);
  .demo-block[hidden] { display: none; }
  body.demo-single .demo-frame { margin-top: 0; }
  body.demo-single .demo-label:first-child { padding-top: var(--kole-space-12); }
    font-size: var(--kole-m-font-size-label); }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="pull">
  <p class="demo-label">下拉中(state=pull,未达阈值 60px)</p>
  <div class="demo-frame" data-assert="pullrefresh-pull">
    <div class="kole-m-pullrefresh is-pulling" style="--kole-m-pullrefresh-offset: 36px">
      <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
        <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
        <span class="kole-m-pullrefresh__text">下拉即可刷新</span>
      </div>
      <div class="kole-m-pullrefresh__content">
        <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
      </div>
    </div>
  </div>
  </section>
  <section class="demo-block" data-demo="ready">
  <p class="demo-label">已达阈值(state=ready,松手立即刷新)</p>
  <div class="demo-frame" data-assert="pullrefresh-ready">
    <div class="kole-m-pullrefresh is-ready" style="--kole-m-pullrefresh-offset: 60px">
      <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
        <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
        <span class="kole-m-pullrefresh__text">松开立即刷新</span>
      </div>
      <div class="kole-m-pullrefresh__content">
        <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
      </div>
    </div>
  </div>
  </section>
  <section class="demo-block" data-demo="refreshing">
  <p class="demo-label">刷新中(state=refreshing,在框内向下拖动触发)</p>
  <div class="demo-frame" data-assert="pullrefresh-refreshing">
    <div class="kole-m-pullrefresh" id="pr-gesture" data-behavior="pull-triggers:#pr-gesture|is-refreshing">
      <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
        <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
        <span class="kole-m-pullrefresh__text">下拉即可刷新</span>
      </div>
      <div class="kole-m-pullrefresh__content">
        <ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
        <button class="kole-m-pullrefresh__fallback" type="button" id="pr-refresh">刷新列表</button>
      </div>
    </div>
  </div>
  </section>
  <section class="demo-block" data-demo="done">
  <p class="demo-label">完成提示(state=done)</p>
  <div class="demo-frame" data-assert="pullrefresh-done">
    <div class="kole-m-pullrefresh is-done" style="--kole-m-pullrefresh-offset: 60px">
      <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
        <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
        <span class="kole-m-pullrefresh__text">刷新完成</span>
      </div>
      <div class="kole-m-pullrefresh__content">
        <ul class="demo-list"><li>订单 20260920-003(新)</li><li>订单 20260920-001</li></ul>
      </div>
    </div>
  </div>
</div>
  </section>

<script>
  /* 手势:Pointer Events;位移以纵向为主 —— 横向分量更大时放弃本次下拉(规格 §4.5)。
     达到阈值松手 → 同步进入 refreshing(可断言),随后异步进入 done 并复位。 */
  (function () {
    var root = document.getElementById('pr-gesture');
    if (!root) return;
    var THRESHOLD = 60;
    var startY = 0, startX = 0, dy = 0, active = false;

    function setOffset(v) { root.style.setProperty('--kole-m-pullrefresh-offset', v + 'px'); }
    function setState(state, text) {
      root.classList.remove('is-pulling', 'is-ready', 'is-refreshing', 'is-done');
      if (state) root.classList.add('is-' + state);
      var t = root.querySelector('.kole-m-pullrefresh__text');
      if (t && text) t.textContent = text;
    }

    root.addEventListener('pointerdown', function (e) {
      if (root.classList.contains('is-refreshing')) return;
      active = true;
      startY = e.clientY;
      startX = e.clientX;
      dy = 0;
    });

    root.addEventListener('pointermove', function (e) {
      if (!active) return;
      dy = e.clientY - startY;
      var dx = Math.abs(e.clientX - startX);
      if (dx > Math.abs(dy)) { active = false; return; }
      if (dy <= 0) { setOffset(0); setState(null); return; }
      setOffset(dy);
      if (dy >= THRESHOLD) setState('ready', '松开立即刷新');
      else setState('pulling', '下拉即可刷新');
    });

    function release() {
      if (!active) return;
      active = false;
      if (dy >= THRESHOLD) refresh();
      else { setOffset(0); setState(null, '下拉即可刷新'); }
    }

    root.addEventListener('pointerup', release);
    root.addEventListener('pointercancel', release);

    function refresh() {
      setOffset(THRESHOLD);
      setState('refreshing', '正在刷新…');
      setTimeout(function () {
        setState('done', '刷新完成');
        setTimeout(function () { setOffset(0); setState(null, '下拉即可刷新'); }, 600);
      }, 300);
    }

    var btn = document.getElementById('pr-refresh');
    if (btn) btn.addEventListener('click', refresh);
  })();
</script>
  <script>
    /* ?demo=<id> → 只显示该演示块:文档站为每个演示单独起一个 375×640 预览帧。
       无参数时全部显示 —— 测试页与回归走无参数路径,行为不变。 */
    (function () {
      var id = new URLSearchParams(location.search).get('demo');
      if (!id) return;
      var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
      var hit = false;
      blocks.forEach(function (b) {
        var on = b.getAttribute('data-demo') === id;
        if (on) hit = true;
        b.hidden = !on;
      });
      if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
      document.body.classList.add('demo-single');
      /* 单块模式去掉 min-height:100vh —— 否则内容高度随帧高变化(帧高→vh→内容高)形成反馈环,
         自适应量到的永远是视口高度而不是内容高度。 */
      var demoBox = document.querySelector('.demo');
      if (demoBox) demoBox.style.minHeight = 'auto';
      /* 单块模式:帧内不再重复显示演示标题行(文档页已给出标题与说明) */
      blocks.forEach(function (b) {
        var label = b.querySelector('.demo-label');
        if (label && !b.hidden) label.hidden = true;
      });
    })();
  </script>
</body>
</html>
frameworks-mobile/PullRefresh.jsx · React · 94 行
frameworks-mobile/PullRefresh.jsx
import React, { useRef, useState } from 'react';
import './PullRefresh.css';

/* 下拉刷新(移动端)— 规格 §4;Pointer Events 手势,阈值默认 60px,
   松手达阈值进入 refreshing;横向分量更大时放弃本次下拉。 */
export default function PullRefresh({
  threshold = 60,
  refreshing = false,
  done = false,
  onRefresh,
  fallbackLabel = '刷新列表',
  children,
}) {
  const [offset, setOffset] = useState(0);
  const [drag, setDrag] = useState('');
  const start = useRef({ y: 0, x: 0 });
  const dy = useRef(0);
  const active = useRef(false);

  const stateText = refreshing
    ? '正在刷新…'
    : done
      ? '刷新完成'
      : drag === 'ready'
        ? '松开立即刷新'
        : '下拉即可刷新';

  const cls =
    'kole-m-pullrefresh' +
    (refreshing ? ' is-refreshing' : done ? ' is-done' : drag ? ' is-' + drag : '');

  function onPointerDown(e) {
    if (refreshing) return;
    active.current = true;
    start.current = { y: e.clientY, x: e.clientX };
    dy.current = 0;
  }

  function onPointerMove(e) {
    if (!active.current) return;
    dy.current = e.clientY - start.current.y;
    if (Math.abs(e.clientX - start.current.x) > Math.abs(dy.current)) {
      active.current = false;
      return;
    }
    if (dy.current <= 0) {
      setOffset(0);
      setDrag('');
      return;
    }
    setOffset(dy.current);
    setDrag(dy.current >= threshold ? 'ready' : 'pulling');
  }

  function release() {
    if (!active.current) return;
    active.current = false;
    if (dy.current >= threshold) {
      setOffset(threshold);
      setDrag('');
      if (onRefresh) onRefresh();
    } else {
      setOffset(0);
      setDrag('');
    }
  }

  return (
    <div
      className={cls}
      style={{ '--kole-m-pullrefresh-offset': offset + 'px' }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={release}
      onPointerCancel={release}
    >
      <div className="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
        <span className="kole-m-pullrefresh__spinner" aria-hidden="true" />
        <span className="kole-m-pullrefresh__text">{stateText}</span>
      </div>
      <div className="kole-m-pullrefresh__content">
        {children}
        <button
          className="kole-m-pullrefresh__fallback"
          type="button"
          onClick={() => onRefresh && onRefresh()}
        >
          {fallbackLabel}
        </button>
      </div>
    </div>
  );
}
frameworks-mobile/PullRefresh.vue2.vue · Vue 2 · 87 行
frameworks-mobile/PullRefresh.vue2.vue
<template>
  <div
    class="kole-m-pullrefresh"
    :class="stateClass"
    :style="{ '--kole-m-pullrefresh-offset': offset + 'px' }"
    @pointerdown="onDown"
    @pointermove="onMove"
    @pointerup="release"
    @pointercancel="release"
  >
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">{{ stateText }}</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <slot></slot>
      <button class="kole-m-pullrefresh__fallback" type="button" @click="$emit('refresh')">
        {{ fallbackLabel }}
      </button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'KoleMPullRefresh',
  props: {
    threshold: { type: Number, default: 60 },
    refreshing: { type: Boolean, default: false },
    done: { type: Boolean, default: false },
    fallbackLabel: { type: String, default: '刷新列表' }
  },
  data: function () {
    return { offset: 0, drag: '', start: { y: 0, x: 0 }, dy: 0, active: false };
  },
  computed: {
    stateClass: function () {
      if (this.refreshing) return 'is-refreshing';
      if (this.done) return 'is-done';
      return this.drag ? 'is-' + this.drag : '';
    },
    stateText: function () {
      if (this.refreshing) return '正在刷新…';
      if (this.done) return '刷新完成';
      return this.drag === 'ready' ? '松开立即刷新' : '下拉即可刷新';
    }
  },
  methods: {
    onDown: function (e) {
      if (this.refreshing) return;
      this.active = true;
      this.start = { y: e.clientY, x: e.clientX };
      this.dy = 0;
    },
    onMove: function (e) {
      if (!this.active) return;
      this.dy = e.clientY - this.start.y;
      if (Math.abs(e.clientX - this.start.x) > Math.abs(this.dy)) {
        this.active = false;
        return;
      }
      if (this.dy <= 0) {
        this.offset = 0;
        this.drag = '';
        return;
      }
      this.offset = this.dy;
      this.drag = this.dy >= this.threshold ? 'ready' : 'pulling';
    },
    release: function () {
      if (!this.active) return;
      this.active = false;
      if (this.dy >= this.threshold) {
        this.offset = this.threshold;
        this.drag = '';
        this.$emit('refresh');
      } else {
        this.offset = 0;
        this.drag = '';
      }
    }
  }
};
</script>

<style src="./PullRefresh.css"></style>
frameworks-mobile/PullRefresh.vue3.vue · Vue 3 · 88 行
frameworks-mobile/PullRefresh.vue3.vue
<template>
  <div
    class="kole-m-pullrefresh"
    :class="stateClass"
    :style="{ '--kole-m-pullrefresh-offset': offset + 'px' }"
    @pointerdown="onDown"
    @pointermove="onMove"
    @pointerup="release"
    @pointercancel="release"
  >
    <div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
      <span class="kole-m-pullrefresh__text">{{ stateText }}</span>
    </div>
    <div class="kole-m-pullrefresh__content">
      <slot></slot>
      <button class="kole-m-pullrefresh__fallback" type="button" @click="emit('refresh')">
        {{ fallbackLabel }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue';

const props = defineProps({
  threshold: { type: Number, default: 60 },
  refreshing: { type: Boolean, default: false },
  done: { type: Boolean, default: false },
  fallbackLabel: { type: String, default: '刷新列表' }
});
const emit = defineEmits(['refresh']);

const offset = ref(0);
const drag = ref('');
const start = ref({ y: 0, x: 0 });
const dy = ref(0);
const active = ref(false);

const stateClass = computed(() => {
  if (props.refreshing) return 'is-refreshing';
  if (props.done) return 'is-done';
  return drag.value ? 'is-' + drag.value : '';
});
const stateText = computed(() => {
  if (props.refreshing) return '正在刷新…';
  if (props.done) return '刷新完成';
  return drag.value === 'ready' ? '松开立即刷新' : '下拉即可刷新';
});

function onDown(e) {
  if (props.refreshing) return;
  active.value = true;
  start.value = { y: e.clientY, x: e.clientX };
  dy.value = 0;
}
function onMove(e) {
  if (!active.value) return;
  dy.value = e.clientY - start.value.y;
  if (Math.abs(e.clientX - start.value.x) > Math.abs(dy.value)) {
    active.value = false;
    return;
  }
  if (dy.value <= 0) {
    offset.value = 0;
    drag.value = '';
    return;
  }
  offset.value = dy.value;
  drag.value = dy.value >= props.threshold ? 'ready' : 'pulling';
}
function release() {
  if (!active.value) return;
  active.value = false;
  if (dy.value >= props.threshold) {
    offset.value = props.threshold;
    drag.value = '';
    emit('refresh');
  } else {
    offset.value = 0;
    drag.value = '';
  }
}
</script>

<style src="./PullRefresh.css"></style>
frameworks-mobile/PullRefresh.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 154 行
frameworks-mobile/PullRefresh.uniapp.vue
<template>
  <view
    class="kole-m-pullrefresh"
    :class="stateClass"
    :style="'--kole-m-pullrefresh-offset:' + offset + 'rpx'"
    @touchstart="onStart"
    @touchmove="onMove"
    @touchend="onEnd"
    @touchcancel="onEnd"
  >
    <view class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
      <view class="kole-m-pullrefresh__spinner" :class="spinClass"></view>
      <text class="kole-m-pullrefresh__text">{{ stateText }}</text>
    </view>
    <view class="kole-m-pullrefresh__content">
      <slot></slot>
      <!-- 规格 §4.6:需保留一个非手势的等价入口 -->
      <view class="kole-m-pullrefresh__fallback" role="button" @tap="emit('refresh')">
        <text>{{ fallbackLabel }}</text>
      </view>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 下拉刷新(移动端)— 规格 §4
   跨端差异(重要):小程序 / App 端没有 PointerEvent,手势只能用
   @touchstart / @touchmove / @touchend,位移取 touch 事件的 clientY;
   阈值 60px 在 rpx 下折算为 120rpx(750rpx = 视口宽度)。
   规格 §4.7 明确「与页面整体下拉的竞争规则」未定,故不对 touchmove 调 preventDefault,
   纵向滚动仍由宿主页面的 scroll-view / page 决定。 */
import { computed, ref } from 'vue';

const props = defineProps({
  /* 触发阈值:uni-app 端的单位是 rpx(750rpx = 视口宽度),默认 120rpx ≈ 60px @375pt。
     契约 api.props.threshold 说明里写明了「浏览器端 px / 本端 rpx」的单位差异。 */
  threshold: { type: Number, default: 120 },
  refreshing: { type: Boolean, default: false },
  done: { type: Boolean, default: false },
  fallbackLabel: { type: String, default: '刷新列表' }
});
const emit = defineEmits(['refresh']);

const offset = ref(0);
const drag = ref('');
const startY = ref(0);
const startX = ref(0);
const dy = ref(0);
const active = ref(false);

const stateClass = computed(() => {
  if (props.refreshing) return 'is-refreshing';
  if (props.done) return 'is-done';
  return drag.value ? 'is-' + drag.value : '';
});
const stateText = computed(() => {
  if (props.refreshing) return '正在刷新…';
  if (props.done) return '刷新完成';
  return drag.value === 'ready' ? '松开立即刷新' : '下拉即可刷新';
});
const spinClass = computed(() => 'is-' + (props.refreshing || props.done ? 'spinning' : 'idle'));

function touch(e) {
  var t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {};
  return { x: t.clientX != null ? t.clientX : t.pageX, y: t.clientY != null ? t.clientY : t.pageY };
}
function onStart(e) {
  if (props.refreshing) return;
  var p = touch(e);
  active.value = true;
  startX.value = p.x;
  startY.value = p.y;
  dy.value = 0;
}
function onMove(e) {
  if (!active.value) return;
  var p = touch(e);
  dy.value = p.y - startY.value;                      /* 单次 touchmove 内用像素判方向,仅用于阈值换算 */
  var dx = Math.abs(p.x - startX.value);
  if (dx > Math.abs(dy.value)) { active.value = false; return; }
  var rpx = dy.value * 2;                             /* 1px ≈ 2rpx(375pt 视口) */
  if (rpx <= 0) { offset.value = 0; drag.value = ''; return; }
  offset.value = rpx;
  drag.value = rpx >= props.threshold ? 'ready' : 'pulling';
}
function onEnd() {
  if (!active.value) return;
  active.value = false;
  if (offset.value >= props.threshold) {
    offset.value = props.threshold;
    drag.value = '';
    emit('refresh');
  } else {
    offset.value = 0;
    drag.value = '';
  }
}
</script>

<style>
.kole-m-pullrefresh {
  --kole-m-pullrefresh-offset: 0rpx;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  position: relative;
  overflow: hidden;
}

.kole-m-pullrefresh__indicator {
  display: flex;
  align-items: center;
  justify-content: center;
  height: var(--kole-m-pullrefresh-offset);
  overflow: hidden;
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-label);
  transition: height 300ms ease-out;
}

.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__indicator,
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__indicator { height: 120rpx; }

.kole-m-pullrefresh__spinner {
  width: 40rpx;
  height: 40rpx;
  border: 4rpx solid var(--kole-color-border);
  border-top-color: var(--kole-color-brand);
  border-radius: 50%;
}

.kole-m-pullrefresh__spinner.is-spinning { animation: kole-m-pullrefresh-spin 800ms linear infinite; }

@keyframes kole-m-pullrefresh-spin {
  to { transform: rotate(360deg); }
}

.kole-m-pullrefresh__content {
  transform: translateY(var(--kole-m-pullrefresh-offset));
  transition: transform 300ms ease-out;
}

.kole-m-pullrefresh__fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--kole-m-touch-target);
  border-top: 1rpx solid var(--kole-color-border);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-brand);
  font-size: var(--kole-m-font-size-label);
}
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 15 条 · 全部通过 报告 2026-09-22 03:04:00

复现命令
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs   # 全量 5 个组件
npm run verify:mobile-docs                                           # 本页内容完整性 + API 与源码一致性

设计契约

components/pullrefresh.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "4 · 下拉刷新 PullRefresh",
  "confidence": "high",
  "slug": "pullrefresh",
  "name": "下拉刷新 PullRefresh",
  "semanticTypeCandidates": [
    "pull-refresh",
    "gesture",
    "list-viewport"
  ],
  "variantDimensions": [
    {
      "name": "state",
      "values": [
        "pull",
        "ready",
        "refreshing",
        "done"
      ]
    },
    {
      "name": "threshold",
      "values": [
        "60"
      ]
    }
  ],
  "representativeVariants": [
    {
      "state": "pull",
      "threshold": "60",
      "label": "下拉中"
    },
    {
      "state": "ready",
      "threshold": "60",
      "label": "已达阈值"
    },
    {
      "state": "refreshing",
      "threshold": "60",
      "label": "刷新中"
    },
    {
      "state": "done",
      "threshold": "60",
      "label": "完成提示"
    }
  ],
  "anatomy": {
    "viewport": "包裹滚动内容的容器,负责手势",
    "indicator": "下拉指示区,含箭头或旋转图标与状态文字",
    "content": "业务内容"
  },
  "structurePatterns": {
    "state": "pull(下拉中)/ ready(已达阈值)/ refreshing(刷新中)/ done(完成提示)",
    "threshold": "触发阈值,默认 60px"
  },
  "usageHints": [
    "列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口",
    "手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑",
    "达到阈值后松手进入 refreshing;未达阈值松手回弹",
    "刷新期间再次下拉不重复触发",
    "指示区 role=status + aria-live=polite,状态文字变化被读屏播报",
    "需保留一个非手势的等价入口(如列表底部的刷新按钮)"
  ],
  "doNotInvent": [
    "惯性与阻尼曲线",
    "与页面整体下拉(浏览器级)的竞争规则"
  ],
  "unknowns": [
    "刷新超时的提示形式",
    "完成提示的停留时长"
  ],
  "interaction": [
    "手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑",
    "达到阈值后松手进入 refreshing;未达阈值松手回弹",
    "刷新期间再次下拉不重复触发"
  ],
  "accessibility": [
    "指示区 role=status + aria-live=polite,状态文字变化被读屏播报",
    "需保留一个非手势的等价入口(如列表底部的刷新按钮)"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "props": [
      {
        "name": "threshold",
        "type": "number",
        "default": "60",
        "desc": "触发阈值:浏览器端(css/h5/react/vue2/vue3)单位为 px,默认 60;uni-app 端单位为 rpx,默认 120(≈ 60px @375pt)",
        "required": false
      },
      {
        "name": "refreshing",
        "type": "boolean",
        "default": "false",
        "desc": "刷新中状态;由业务侧在 refresh 事件后置位(规格 §4.4)",
        "required": false
      },
      {
        "name": "done",
        "type": "boolean",
        "default": "false",
        "desc": "完成提示状态(规格 §4.4)",
        "required": false
      },
      {
        "name": "fallbackLabel",
        "type": "string",
        "default": "'刷新列表'",
        "desc": "非手势等价入口的文案(规格 §4.6)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "refresh",
        "params": "—",
        "desc": "松手达到阈值时触发(手动点等价入口同样触发)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "列表内容;组件在外层包裹手势与指示区(规格 §4.2)"
      }
    ],
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。"
  },
  "variantClasses": {
    "state": {
      "pull": [
        ".is-pulling"
      ],
      "ready": [
        ".is-ready"
      ],
      "refreshing": [
        ".is-refreshing"
      ],
      "done": [
        ".is-done"
      ]
    },
    "threshold": {
      "60": [
        "--kole-m-pullrefresh-threshold"
      ]
    }
  },
  "demos": [
    {
      "id": "pull",
      "group": "01 组件类型",
      "title": "下拉中",
      "desc": "未达阈值:指示器随位移旋转,提示「下拉即可刷新」。",
      "variant": "state=pull"
    },
    {
      "id": "ready",
      "group": "01 组件类型",
      "title": "已达阈值",
      "desc": "达到 60px 提示改为「松开立即刷新」,松手即进入刷新。",
      "variant": "state=ready"
    },
    {
      "id": "refreshing",
      "group": "01 组件类型",
      "title": "刷新中",
      "desc": "指示器持续旋转、下拉不回弹;期间再次下拉不重复触发。",
      "variant": "state=refreshing"
    },
    {
      "id": "done",
      "group": "01 组件类型",
      "title": "完成提示",
      "desc": "完成态短暂停留后复位(停留时长规格未定,见「规格未定」)。",
      "variant": "state=done"
    }
  ],
  "related": [
    {
      "slug": "swipecell",
      "why": "同属手势交互:下拉是纵向、滑动是横向,两者靠方向判定让位"
    },
    {
      "slug": "navbar",
      "why": "刷新常与顶栏配合(刷新后更新标题或角标)"
    }
  ]
}