下拉更新(React)
行動端下拉更新容器的 React 元件:手勢阻尼與門檻、箭頭隨拉距旋轉變 spinner、onRefresh 回傳 Promise、完成打勾回收,桌機提供按鈕後備。
固定高度的可捲動容器,內容捲到最頂時往下拉就會拉出圓形指示器:拉距 = 手指位移 × 阻尼、到上限就不再變長,箭頭隨拉距從 0 轉到 180 度、達門檻時圓卡變深色;放開時超過門檻就停在門檻位置換成 spinner 並 await onRefresh(),完成後打勾停留 0.6 秒再 spring 回收,未達門檻則直接回彈。手勢用 pointer 事件實作,滑鼠也能拖,另在右上角提供「重新整理」按鈕給桌機、鍵盤與螢幕閱讀器使用;沒接管手勢時清單維持原生捲動。
載入預覽⋯
npx shadcn@latest add https://webberui.com/r/pull-to-refresh.jsonPlayground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
72
0.5
360
<PullToRefresh />
安裝
npx shadcn@latest add https://webberui.com/r/pull-to-refresh.json或在 components.json 設定 registries 後,改用 @webberui/pull-to-refresh 安裝。
使用
import { PullToRefresh } from "@/components/ui/pull-to-refresh";
// 基本:onRefresh 回傳 Promise,resolve 後打勾回收
<PullToRefresh
height={360}
onRefresh={async () => {
const latest = await fetchMessages();
setMessages(latest);
}}
>
<ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
</PullToRefresh>
// 調整手感、換文字、自訂指示器
<PullToRefresh
threshold={80}
maxPull={140}
resistance={0.6}
labels={{ pull: "下拉載入", release: "放開載入", refreshing: "載入中", done: "完成" }}
indicator={({ phase, progress, label }) => (
<div className="rounded-full bg-black px-3 py-1 text-xs text-white">
{phase === "pulling" ? `${Math.round(progress * 100)}%` : label}
</div>
)}
onRefresh={reload}
>
{children}
</PullToRefresh>Props
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
onRefresh | () => Promise<void> | — | 必填。觸發更新時呼叫;Promise 完成後顯示打勾 0.6 秒再回收,失敗則直接回收(不顯示打勾),錯誤請在函式內自行處理 |
children | React.ReactNode | — | 可捲動的內容 |
threshold | number | 72 | 觸發更新所需的拉距(px) |
maxPull | number | 120 | 拉距上限(px),至少等於 threshold |
resistance | number | 0.5 | 阻尼係數:手指位移 × resistance 才是實際拉距 |
disabled | boolean | false | 停用下拉手勢與後備按鈕 |
height | number | 360 | 容器固定高度(px);內部 overflow-y-auto |
indicator | (state: PullToRefreshIndicatorState) => React.ReactNode | — | 自訂指示器渲染;回傳 null 則沿用內建圓卡 |
labels | PullToRefreshLabels | 見下表 | 各階段的狀態文字:aria-live 朗讀用,減少動態效果時也直接顯示 |
showDesktopButton | boolean | true | 右上角顯示「重新整理」按鈕,作為滑鼠、鍵盤與螢幕閱讀器的後備 |
className | string | — | 透傳到最外層容器 |
PullToRefreshLabels
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
pull | string | "下拉更新" | 下拉中、尚未達門檻 |
release | string | "放開更新" | 已達門檻,放開即更新 |
refreshing | string | "更新中" | 更新中(等待 onRefresh 的 Promise) |
done | string | "已更新" | 更新完成,打勾停留期間 |
button | string | "重新整理" | 右上角後備按鈕的 aria-label 與 title |
PullToRefreshIndicatorState
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
phase | "idle" | "pulling" | "refreshing" | "done" | — | 目前階段 |
progress | number | — | 拉距相對門檻的進度(0–1;超過門檻仍為 1) |
armed | boolean | — | 是否已達門檻(放開就會更新) |
pull | number | — | 目前拉距(px,已套用阻尼與上限) |
threshold | number | — | 觸發門檻(px) |
label | string | — | 對應目前階段的狀態文字(idle 時為 labels.pull) |
另外具名匯出 PullToRefreshProps、PullToRefreshPhase、PullToRefreshLabels、PullToRefreshIndicatorState 型別。
細節
- 接管條件:
pointerdown時內部捲動容器scrollTop必須為 0,且第一段位移往下、以垂直為主,才會接管手勢;往上或橫向就整段交給原生捲動,捲到一半的清單下拉也不會誤觸發。滑鼠按在捲軸上的拖曳一律留給捲軸 - 擋原生捲動:
pointermove裡的preventDefault擋不住原生捲動,所以另掛一個非 passive 的原生touchmove監聽,接管後才preventDefault(同時也在這裡判定方向,不依賴 pointer/touch 事件的派發順序);接管期間容器加上touch-action: pan-x與user-select: none - 拉距是唯一的動畫來源(motion value):內容位移、指示器的位置/透明度/縮放、箭頭角度全部由它推導,拖曳過程不觸發 React 重繪;只有提供自訂
indicator時才會把拉距同步成 state 給它 - 放開後:拉距 ≥
threshold就 spring 到門檻位置並鎖住,await onRefresh();成功 → 打勾停留 600ms → spring 回收;失敗 → 直接回收。回收期間與更新中都不接受新手勢與按鈕 - 拖曳結束後緊接著的
click會被吞掉,避免鬆手時誤點到清單裡的按鈕;桌機後備按鈕在更新中不設disabled(焦點會掉出),改以aria-busy表示
可及性
- 狀態文字放在
role="status"、aria-live="polite"的隱藏區塊,下拉中/放開更新/更新中/已更新的變化會被螢幕閱讀器朗讀;後備按鈕以aria-describedby指向它 - 右上角後備按鈕為原生
button,帶aria-label、title與focus-visiblering,鍵盤與螢幕閱讀器不需要手勢也能觸發更新 - 使用者系統開啟「減少動態效果」時,內容不再跟著手指位移、指示器改為固定在頂端的狀態文字膠囊(下拉更新/放開更新/更新中/已更新),spinner 不旋轉,回彈與回收皆不做動畫