下拉刷新PullRefresh
列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口
反馈 规格 4 · 下拉刷新 PullRefresh 6 端实现 触摸优先
<!-- ① 令牌: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 组件类型
未达阈值:指示器随位移旋转,提示「下拉即可刷新」。
查看代码(演示页原文 · 14 行)
<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>达到 60px 提示改为「松开立即刷新」,松手即进入刷新。
查看代码(演示页原文 · 14 行)
<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>指示器持续旋转、下拉不回弹;期间再次下拉不重复触发。
查看代码(演示页原文 · 15 行)
<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>完成态短暂停留后复位(停留时长规格未定,见「规格未定」)。
查看代码(演示页原文 · 15 行)
<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
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
threshold | number | 60 | 触发阈值:浏览器端(css/h5/react/vue2/vue3)单位为 px,默认 60;uni-app 端单位为 rpx,默认 120(≈ 60px @375pt) | N |
refreshing | boolean | false | 刷新中状态;由业务侧在 refresh 事件后置位(规格 §4.4) | N |
done | boolean | false | 完成提示状态(规格 §4.4) | N |
fallbackLabel | string | '刷新列表' | 非手势等价入口的文案(规格 §4.6) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
refresh | — | 松手达到阈值时触发(手动点等价入口同样触发) |
插槽
| 名称 | 说明 |
|---|---|
default | 列表内容;组件在外层包裹手势与指示区(规格 §4.2) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-pullrefresh-threshold | 60px | 组件内部默认值,可在业务侧覆盖 |
--kole-m-pullrefresh-offset | 0px | 组件内部默认值,可在业务侧覆盖 |
何时使用
- 列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口
- 手势使用 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 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
state | pull / ready / refreshing / done | pull .is-pulling ready .is-ready refreshing .is-refreshing done .is-done |
threshold | 60 | 60 --kole-m-pullrefresh-threshold |
代表变体
| 变体 | 标签 |
|---|---|
state=pull · threshold=60 | 下拉中 |
state=ready · threshold=60 | 已达阈值 |
state=refreshing · threshold=60 | 刷新中 |
state=done · threshold=60 | 完成提示 |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/PullRefresh.css · 纯样式(CSS) · 100 行
/* 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 行
<!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 行
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 行
<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 行
<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 行
<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-20 16:50:33
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": "刷新常与顶栏配合(刷新后更新标题或角标)"
}
]
}