WebberUI

下拉更新(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.json

Playground

即時調整 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 秒再回收,失敗則直接回收(不顯示打勾),錯誤請在函式內自行處理
childrenReact.ReactNode可捲動的內容
thresholdnumber72觸發更新所需的拉距(px)
maxPullnumber120拉距上限(px),至少等於 threshold
resistancenumber0.5阻尼係數:手指位移 × resistance 才是實際拉距
disabledbooleanfalse停用下拉手勢與後備按鈕
heightnumber360容器固定高度(px);內部 overflow-y-auto
indicator(state: PullToRefreshIndicatorState) => React.ReactNode自訂指示器渲染;回傳 null 則沿用內建圓卡
labelsPullToRefreshLabels見下表各階段的狀態文字:aria-live 朗讀用,減少動態效果時也直接顯示
showDesktopButtonbooleantrue右上角顯示「重新整理」按鈕,作為滑鼠、鍵盤與螢幕閱讀器的後備
classNamestring透傳到最外層容器

PullToRefreshLabels

欄位型別預設值說明
pullstring"下拉更新"下拉中、尚未達門檻
releasestring"放開更新"已達門檻,放開即更新
refreshingstring"更新中"更新中(等待 onRefresh 的 Promise)
donestring"已更新"更新完成,打勾停留期間
buttonstring"重新整理"右上角後備按鈕的 aria-label 與 title

PullToRefreshIndicatorState

欄位型別預設值說明
phase"idle" | "pulling" | "refreshing" | "done"目前階段
progressnumber拉距相對門檻的進度(0–1;超過門檻仍為 1)
armedboolean是否已達門檻(放開就會更新)
pullnumber目前拉距(px,已套用阻尼與上限)
thresholdnumber觸發門檻(px)
labelstring對應目前階段的狀態文字(idle 時為 labels.pull

另外具名匯出 PullToRefreshPropsPullToRefreshPhasePullToRefreshLabelsPullToRefreshIndicatorState 型別。

細節

  • 接管條件:pointerdown 時內部捲動容器 scrollTop 必須為 0,且第一段位移往下、以垂直為主,才會接管手勢;往上或橫向就整段交給原生捲動,捲到一半的清單下拉也不會誤觸發。滑鼠按在捲軸上的拖曳一律留給捲軸
  • 擋原生捲動:pointermove 裡的 preventDefault 擋不住原生捲動,所以另掛一個非 passive 的原生 touchmove 監聽,接管後才 preventDefault(同時也在這裡判定方向,不依賴 pointer/touch 事件的派發順序);接管期間容器加上 touch-action: pan-xuser-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-labeltitlefocus-visible ring,鍵盤與螢幕閱讀器不需要手勢也能觸發更新
  • 使用者系統開啟「減少動態效果」時,內容不再跟著手指位移、指示器改為固定在頂端的狀態文字膠囊(下拉更新/放開更新/更新中/已更新),spinner 不旋轉,回彈與回收皆不做動畫

本頁目錄