<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Button-States on Tarragon</title><link>https://tarrragon.github.io/blog/tags/button-states/</link><description>Recent content in Button-States on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 16 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/button-states/index.xml" rel="self" type="application/rss+xml"/><item><title>互動回饋三層模型：點擊確認、等待指示、結果通知</title><link>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/feedback-three-layers/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/feedback-three-layers/</guid><description>&lt;h2 id="核心觀念">核心觀念&lt;/h2>
&lt;p>介面設計中，&lt;strong>使用者每執行一個動作，系統都必須提供回饋&lt;/strong>。回饋是可用性的基本要求。沒有回饋的按鈕，使用者無法區分「系統在處理」和「按鈕壞了」。&lt;/p>
&lt;p>這個原則對應 Jakob Nielsen 十大可用性啟發法的第一條：&lt;strong>系統狀態可見性（Visibility of System Status）&lt;/strong> — 系統應透過適當的回饋，在合理時間內告知使用者目前發生了什麼。&lt;/p>
&lt;h2 id="三層回饋模型">三層回饋模型&lt;/h2>
&lt;p>使用者按下按鈕後，回饋依時間順序分三層：&lt;/p>
&lt;h3 id="第一層點擊確認0-100ms">第一層：點擊確認（0-100ms）&lt;/h3>
&lt;p>系統確認「收到你的操作了」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>回饋形式&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>適用場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>視覺狀態變化&lt;/td>
 &lt;td>按鈕顏色變深、凹陷效果&lt;/td>
 &lt;td>所有按鈕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>水波紋效果&lt;/td>
 &lt;td>Material Design ripple&lt;/td>
 &lt;td>觸控介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>觸覺回饋&lt;/td>
 &lt;td>震動&lt;/td>
 &lt;td>行動裝置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>聲音回饋&lt;/td>
 &lt;td>輕微點擊聲&lt;/td>
 &lt;td>無障礙輔助&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>設計原則&lt;/strong>：第一層回饋必須在 100ms 內出現。這個門檻來自人類感知研究 — 100ms 以內的延遲被感知為即時反應，超過後點擊與回饋之間開始出現可察覺的落差，回饋就失去「確認」的效果。平台原生元件的 pressed / ripple 互動態已滿足這個門檻，用平台預設互動態即自動合格；自建元件（自繪按鈕、canvas UI）才需要自行驗證回饋延遲。&lt;/p>
&lt;h3 id="第二層等待指示100ms---10s">第二層：等待指示（100ms - 10s）&lt;/h3>
&lt;p>系統告知「正在處理你的請求」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>等待時長&lt;/th>
 &lt;th>建議回饋&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&amp;lt; 400ms&lt;/td>
 &lt;td>不需額外指示&lt;/td>
 &lt;td>Doherty Threshold：400ms 內完成、人機互動不需互相等待&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>400ms - 1s&lt;/td>
 &lt;td>Spinner / 按鈕 loading 狀態&lt;/td>
 &lt;td>簡單指示「還在跑」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1s - 10s&lt;/td>
 &lt;td>進度條或步驟指示&lt;/td>
 &lt;td>告知使用者「到哪了」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;gt; 10s&lt;/td>
 &lt;td>進度百分比 + 預估剩餘時間&lt;/td>
 &lt;td>超過 10s 使用者開始考慮放棄&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>設計原則&lt;/strong>：等待期間必須禁用觸發按鈕（disabled 狀態），防止使用者因焦慮而重複點擊導致重複提交。UI 層的防重複提交降低發生率；最終防線是伺服器端的冪等設計 — 重複請求真的到達後端時要能被識別與去重。兩層都要做，本系列只涵蓋 UI 層。&lt;/p>
&lt;p>&lt;strong>400ms 門檻（&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold&lt;/a>）&lt;/strong> 的實務意義：非同步操作通常在 400ms 內完成時，可省略 loading 指示器、直接跳到第三層的結果回饋（點擊確認仍要在第一層提供）。這條門檻的出身與適用邊界、以及實際延遲有快有慢時怎麼對表，&lt;a href="../response-time-strategy/">時間感知與回應策略&lt;/a>有完整推導。&lt;/p>
&lt;h3 id="第三層結果通知">第三層：結果通知&lt;/h3>
&lt;p>系統告知「操作的結果是什麼」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>結果&lt;/th>
 &lt;th>回饋形式&lt;/th>
 &lt;th>設計要求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>成功&lt;/td>
 &lt;td>畫面更新 / 成功提示 / 跳轉&lt;/td>
 &lt;td>讓使用者看到操作造成的改變&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗&lt;/td>
 &lt;td>錯誤訊息 + 可執行動作&lt;/td>
 &lt;td>告知原因 + 提供下一步（重試 / 替代方案）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空結果&lt;/td>
 &lt;td>空狀態畫面&lt;/td>
 &lt;td>區別「沒找到」和「沒載入」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>部分成功&lt;/td>
 &lt;td>摘要 + 明細&lt;/td>
 &lt;td>告知哪些成功哪些失敗&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>設計原則&lt;/strong>：結果通知必須恢復按鈕到可操作狀態。操作結束後按鈕仍顯示 loading = 介面凍結。&lt;/p>
&lt;p>&lt;strong>空結果的呈現&lt;/strong>：空狀態畫面要讓使用者分得出「查詢成功但沒有資料」和「還沒載入完」— 給明確文字（「沒有符合的結果」）與下一步建議（放寬條件、建立第一筆資料），載入中則維持等待指示。空狀態與錯誤狀態的措辭由&lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">模組四：錯誤訊息撰寫原則&lt;/a>接手。&lt;/p>
&lt;p>&lt;strong>部分成功的呈現&lt;/strong>：批次操作（匯入 100 筆、87 筆成功）用摘要加明細兩層：摘要先告知整體結果（「87 筆成功、13 筆失敗」），明細列出失敗項與各自的修正動作，讓使用者只需處理失敗的部分、而非重跑整批。&lt;/p>
&lt;p>&lt;strong>結果通知的鏈路前提&lt;/strong>：第三層的設計都假設「結果會正確到達 UI」。UI 與執行端分屬不同 context 時（browser extension 的 popup / content script / service worker、跨 process 架構），結果要跨訊息通道才到 UI — 通道語意錯誤或事件訂閱缺失會讓通知消失、甚至反轉。一個 Chrome extension 提取成功 96 本且已寫入 storage，popup 卻顯示「提取失敗」— async listener 把 undefined 搶先當回應送回（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/async-listener-false-failure/" data-link-title="U.C9 提取成功卻誤報失敗 — 結果通知鏈路被搶通道" data-link-desc="操作實際成功、資料已寫入，UI 卻顯示失敗時使用。多 context 的訊息通道語意（誰負責回應）是結果通知鏈路的一部分，async listener 搶通道會把 undefined 當成回應送回">U.C9&lt;/a>）。跨 context 的結果通知要有端對端驗證（操作成功 → UI 顯示成功），不只測 UI 收到資料後的呈現。&lt;/p>
&lt;p>本篇聚焦「該通知什麼」；SnackBar / Dialog / Banner / Bottom Sheet 之間的形式選擇由&lt;a href="../notification-pattern-selection/">通知模式選擇&lt;/a>展開。螢幕閱讀器的通知宣告（aria-live / aria-busy）屬無障礙實作，不在本模組範圍。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心觀念">核心觀念</h2>
<p>介面設計中，<strong>使用者每執行一個動作，系統都必須提供回饋</strong>。回饋是可用性的基本要求。沒有回饋的按鈕，使用者無法區分「系統在處理」和「按鈕壞了」。</p>
<p>這個原則對應 Jakob Nielsen 十大可用性啟發法的第一條：<strong>系統狀態可見性（Visibility of System Status）</strong> — 系統應透過適當的回饋，在合理時間內告知使用者目前發生了什麼。</p>
<h2 id="三層回饋模型">三層回饋模型</h2>
<p>使用者按下按鈕後，回饋依時間順序分三層：</p>
<h3 id="第一層點擊確認0-100ms">第一層：點擊確認（0-100ms）</h3>
<p>系統確認「收到你的操作了」。</p>
<table>
  <thead>
      <tr>
          <th>回饋形式</th>
          <th>範例</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>視覺狀態變化</td>
          <td>按鈕顏色變深、凹陷效果</td>
          <td>所有按鈕</td>
      </tr>
      <tr>
          <td>水波紋效果</td>
          <td>Material Design ripple</td>
          <td>觸控介面</td>
      </tr>
      <tr>
          <td>觸覺回饋</td>
          <td>震動</td>
          <td>行動裝置</td>
      </tr>
      <tr>
          <td>聲音回饋</td>
          <td>輕微點擊聲</td>
          <td>無障礙輔助</td>
      </tr>
  </tbody>
</table>
<p><strong>設計原則</strong>：第一層回饋必須在 100ms 內出現。這個門檻來自人類感知研究 — 100ms 以內的延遲被感知為即時反應，超過後點擊與回饋之間開始出現可察覺的落差，回饋就失去「確認」的效果。平台原生元件的 pressed / ripple 互動態已滿足這個門檻，用平台預設互動態即自動合格；自建元件（自繪按鈕、canvas UI）才需要自行驗證回饋延遲。</p>
<h3 id="第二層等待指示100ms---10s">第二層：等待指示（100ms - 10s）</h3>
<p>系統告知「正在處理你的請求」。</p>
<table>
  <thead>
      <tr>
          <th>等待時長</th>
          <th>建議回饋</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>&lt; 400ms</td>
          <td>不需額外指示</td>
          <td>Doherty Threshold：400ms 內完成、人機互動不需互相等待</td>
      </tr>
      <tr>
          <td>400ms - 1s</td>
          <td>Spinner / 按鈕 loading 狀態</td>
          <td>簡單指示「還在跑」</td>
      </tr>
      <tr>
          <td>1s - 10s</td>
          <td>進度條或步驟指示</td>
          <td>告知使用者「到哪了」</td>
      </tr>
      <tr>
          <td>&gt; 10s</td>
          <td>進度百分比 + 預估剩餘時間</td>
          <td>超過 10s 使用者開始考慮放棄</td>
      </tr>
  </tbody>
</table>
<p><strong>設計原則</strong>：等待期間必須禁用觸發按鈕（disabled 狀態），防止使用者因焦慮而重複點擊導致重複提交。UI 層的防重複提交降低發生率；最終防線是伺服器端的冪等設計 — 重複請求真的到達後端時要能被識別與去重。兩層都要做，本系列只涵蓋 UI 層。</p>
<p><strong>400ms 門檻（<a href="/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold</a>）</strong> 的實務意義：非同步操作通常在 400ms 內完成時，可省略 loading 指示器、直接跳到第三層的結果回饋（點擊確認仍要在第一層提供）。這條門檻的出身與適用邊界、以及實際延遲有快有慢時怎麼對表，<a href="../response-time-strategy/">時間感知與回應策略</a>有完整推導。</p>
<h3 id="第三層結果通知">第三層：結果通知</h3>
<p>系統告知「操作的結果是什麼」。</p>
<table>
  <thead>
      <tr>
          <th>結果</th>
          <th>回饋形式</th>
          <th>設計要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>成功</td>
          <td>畫面更新 / 成功提示 / 跳轉</td>
          <td>讓使用者看到操作造成的改變</td>
      </tr>
      <tr>
          <td>失敗</td>
          <td>錯誤訊息 + 可執行動作</td>
          <td>告知原因 + 提供下一步（重試 / 替代方案）</td>
      </tr>
      <tr>
          <td>空結果</td>
          <td>空狀態畫面</td>
          <td>區別「沒找到」和「沒載入」</td>
      </tr>
      <tr>
          <td>部分成功</td>
          <td>摘要 + 明細</td>
          <td>告知哪些成功哪些失敗</td>
      </tr>
  </tbody>
</table>
<p><strong>設計原則</strong>：結果通知必須恢復按鈕到可操作狀態。操作結束後按鈕仍顯示 loading = 介面凍結。</p>
<p><strong>空結果的呈現</strong>：空狀態畫面要讓使用者分得出「查詢成功但沒有資料」和「還沒載入完」— 給明確文字（「沒有符合的結果」）與下一步建議（放寬條件、建立第一筆資料），載入中則維持等待指示。空狀態與錯誤狀態的措辭由<a href="/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">模組四：錯誤訊息撰寫原則</a>接手。</p>
<p><strong>部分成功的呈現</strong>：批次操作（匯入 100 筆、87 筆成功）用摘要加明細兩層：摘要先告知整體結果（「87 筆成功、13 筆失敗」），明細列出失敗項與各自的修正動作，讓使用者只需處理失敗的部分、而非重跑整批。</p>
<p><strong>結果通知的鏈路前提</strong>：第三層的設計都假設「結果會正確到達 UI」。UI 與執行端分屬不同 context 時（browser extension 的 popup / content script / service worker、跨 process 架構），結果要跨訊息通道才到 UI — 通道語意錯誤或事件訂閱缺失會讓通知消失、甚至反轉。一個 Chrome extension 提取成功 96 本且已寫入 storage，popup 卻顯示「提取失敗」— async listener 把 undefined 搶先當回應送回（<a href="/blog/ux-design/cases/async-listener-false-failure/" data-link-title="U.C9 提取成功卻誤報失敗 — 結果通知鏈路被搶通道" data-link-desc="操作實際成功、資料已寫入，UI 卻顯示失敗時使用。多 context 的訊息通道語意（誰負責回應）是結果通知鏈路的一部分，async listener 搶通道會把 undefined 當成回應送回">U.C9</a>）。跨 context 的結果通知要有端對端驗證（操作成功 → UI 顯示成功），不只測 UI 收到資料後的呈現。</p>
<p>本篇聚焦「該通知什麼」；SnackBar / Dialog / Banner / Bottom Sheet 之間的形式選擇由<a href="../notification-pattern-selection/">通知模式選擇</a>展開。螢幕閱讀器的通知宣告（aria-live / aria-busy）屬無障礙實作，不在本模組範圍。</p>
<h2 id="兩類按鈕的回饋設計">兩類按鈕的回饋設計</h2>
<p>依操作的時間特性，按鈕分兩類（各狀態的視覺設計在<a href="../button-state-design/">按鈕狀態設計</a>展開）：</p>
<h3 id="非同步按鈕api--資料庫--權限請求">非同步按鈕（API / 資料庫 / 權限請求）</h3>
<p>完整三層回饋：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[idle] → 點擊 → [loading: disabled + spinner] → 完成 → [idle + 結果通知]</span></span></code></pre></div><p>設計要點：</p>
<ul>
<li>loading 期間按鈕 disabled（防重複提交）</li>
<li>按鈕文字可變（「送出」→「送出中&hellip;」）</li>
<li>結束後恢復 idle 狀態</li>
</ul>
<h3 id="同步按鈕導航--切換--開關">同步按鈕（導航 / 切換 / 開關）</h3>
<p>僅需第一層 + 防連點：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[idle] → 點擊 → [視覺回饋 + 執行] → [完成]</span></span></code></pre></div><p>設計要點：</p>
<ul>
<li>操作在 100ms 內完成，不需 loading</li>
<li>加入防連點（300ms 上下的慣例值）避免快速連點觸發多次導航</li>
<li>視覺回饋（ripple / 狀態切換）即足夠</li>
</ul>
<p><strong>防連點的語意</strong>：第一次點擊立即執行、之後 300ms 內的點擊忽略（leading-edge <a href="/blog/ux-design/knowledge-cards/debounce/" data-link-title="Debounce（防連點）" data-link-desc="說明合併短時間內重複觸發的技巧，以及 leading-edge 與 trailing-edge 兩種執行語意在按鈕防連點上的差別">debounce</a>），或用導航鎖（in-flight flag）在導航完成前拒絕新請求。反過來「等 300ms 沒有新點擊才執行」（trailing debounce）會給每次導航加上 300ms 延遲，直接違反第一層的 100ms 門檻 — 防連點防的是第二次點擊，第一次要立即生效。</p>
<h2 id="畫面級回饋多步驟流程的狀態轉換">畫面級回饋：多步驟流程的狀態轉換</h2>
<p>上述按鈕級回饋處理的是「按一個按鈕」的單一操作。但有些使用者流程涉及<strong>整個畫面的狀態轉換</strong> — 連線、配對、同步、多步驟表單 — 每個中間狀態都需要能被使用者區辨的 UI 回饋。</p>
<h3 id="與按鈕級的差異">與按鈕級的差異</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>按鈕級回饋</th>
          <th>畫面級回饋</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>範圍</td>
          <td>單一按鈕內的狀態變化</td>
          <td>整個畫面 UI 替換</td>
      </tr>
      <tr>
          <td>狀態數</td>
          <td>2-3 個（idle / loading / result）</td>
          <td>4-6 個（idle / processing / success / error / disconnected&hellip;）</td>
      </tr>
      <tr>
          <td>退出路徑</td>
          <td>不需要（按鈕自己恢復）</td>
          <td>每個中間狀態都必須有退出路徑</td>
      </tr>
      <tr>
          <td>設計工具</td>
          <td>按鈕狀態檢查清單</td>
          <td><a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">畫面狀態矩陣（模組一）</a></td>
      </tr>
  </tbody>
</table>
<h3 id="設計模式">設計模式</h3>
<p>以連線流程為例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[idle] ─點擊連線→ [connecting] ─成功→ [connected]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                       │                    │
</span></span><span class="line"><span class="ln">3</span><span class="cl">                       ├─失敗→ [error]       ├─斷線→ [disconnected]
</span></span><span class="line"><span class="ln">4</span><span class="cl">                       └─取消→ [idle]        └─手動斷開→ [idle]</span></span></code></pre></div><p>每個狀態的回饋設計：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>UI 呈現</th>
          <th>可用操作</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>連線按鈕</td>
          <td>點擊連線</td>
          <td>返回上一頁</td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>全頁 spinner + 「連線中」文字</td>
          <td>取消</td>
          <td>取消 → idle</td>
      </tr>
      <tr>
          <td>connected</td>
          <td>功能畫面</td>
          <td>使用功能 / 斷開</td>
          <td>斷開 → idle / 返回</td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重連 / 返回</td>
          <td>重連 → connecting / 返回 → idle</td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重連 / 返回</td>
          <td>重連 → connecting / 返回 → idle</td>
      </tr>
  </tbody>
</table>
<p><strong>關鍵設計原則</strong>：error 和 disconnected 必須同時提供「重連」和「返回」兩條路徑。只有重連會把使用者鎖在錯誤迴圈（如果問題無法透過重連解決，使用者永遠出不去）。</p>
<p>timeout 是 connecting 的隱形退出路徑：取消靠使用者主動，timeout 是系統兜底 — 取值略大於 client / 後端的逾時設定（讓真正的失敗先回來、而非 UI 提前放棄），逾時後轉入 error 狀態走「重連 / 返回」。</p>
<h3 id="畫面級回饋的檢查清單">畫面級回饋的檢查清單</h3>
<ul>
<li><input disabled="" type="checkbox"> 每個中間狀態能被使用者區辨（獨立畫面、或同畫面上明確的狀態指示）？</li>
<li><input disabled="" type="checkbox"> 每個中間狀態有退出路徑（對應<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>）？</li>
<li><input disabled="" type="checkbox"> 等待狀態提供取消操作？</li>
<li><input disabled="" type="checkbox"> 錯誤 / 中斷狀態提供重試<strong>和</strong>返回兩條路徑？</li>
<li><input disabled="" type="checkbox"> 長時間等待有 timeout 機制（不會永久卡在 connecting）？</li>
</ul>
<h2 id="反模式">反模式</h2>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>問題</th>
          <th>使用者行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>按鈕無任何視覺回饋</td>
          <td>使用者不確定有沒有按到</td>
          <td>重複點擊</td>
      </tr>
      <tr>
          <td>非同步操作不禁用按鈕</td>
          <td>loading 期間仍可點擊</td>
          <td>重複提交（雙重扣款、重複建立）</td>
      </tr>
      <tr>
          <td>loading 結束不恢復按鈕</td>
          <td>操作完成但按鈕仍 disabled</td>
          <td>使用者以為介面當掉</td>
      </tr>
      <tr>
          <td>只有 loading 無結果通知</td>
          <td>spinner 消失但畫面沒變化</td>
          <td>使用者不確定操作是否成功</td>
      </tr>
      <tr>
          <td>同步按鈕無 debounce</td>
          <td>快速點擊觸發多次導航</td>
          <td>導航堆疊混亂（push 多次）</td>
      </tr>
      <tr>
          <td>畫面中間狀態無退出路徑</td>
          <td>使用者被卡在 connecting</td>
          <td>只能殺 app（UX 死胡同）</td>
      </tr>
      <tr>
          <td>錯誤狀態只有重連沒有返回</td>
          <td>問題無法重連解決時被鎖住</td>
          <td>在錯誤迴圈中反覆重連</td>
      </tr>
  </tbody>
</table>
<p>表格第一行（按鈕無任何視覺回饋）的完整實證案例見 <a href="/blog/ux-design/cases/export-button-zero-feedback/" data-link-title="U.C5 匯出按鈕按下零回饋 — 狀態機完備但 UI 沒接線" data-link-desc="Flutter app 匯出設定頁的確認匯出按鈕 onPressed 是空 callback，按下畫面毫無變化 — 使用者無法分辨匯出成功、進行中、還是功能根本沒做。ViewModel 的 idle/inProgress/completed/failed 狀態機早已完備，缺的只是頁面接線與三層回饋">U.C5 匯出按鈕零回饋</a>——狀態機完備但 UI 沒接線，三層回饋全缺。同族的兩個變體：佔位 handler 上線 — 按鈕存在但 onPressed 只接開發提示或 log，dev toast 讓開發自測「有反應」、掩蓋未接線（<a href="/blog/ux-design/cases/management-actions-placeholder-only/" data-link-title="U.C20 管理模式操作全是佔位 — dev toast 讓未接線看起來有反應" data-link-desc="UI 有按鈕、domain 層功能也寫完、按下去只有開發提示或 log：佔位 handler 比沒有按鈕更糟 — 按鈕的存在承諾功能存在，dev toast 讓開發自測「有反應」、掩蓋未接線">U.C20</a>）；回饋文字被版面擠壓 — state 有值、綁定正確，flex 寬度競爭把「已選擇: 3/45」壓成「&hellip;」（<a href="/blog/ux-design/cases/selection-count-layout-starvation/" data-link-title="U.C19 「已選擇」計數被版面擠壓成省略號 — 回饋死在 layout" data-link-desc="狀態文字顯示成「...」、資料層查起來卻一切正常 — 回饋鏈的最後一哩是版面，flex 寬度競爭把關鍵計數整串壓成省略號，state 正確、使用者拿到零資訊">U.C19</a>）。回饋鏈要驗到使用者的眼睛，state 有值、widget 有綁都不是終點。</p>
<p>版面擠壓類問題有一個常見的誤歸因：專案用了等比縮放套件（如 flutter_screenutil），版面出事時第一反應是查套件設定。換算工具工作在常數層（設計稿座標 → 裝置座標的數值換算）、空間分配發生在 layout 協商層（有限寬度分給動態內容）— 兩層獨立，「有用響應式套件」不構成版面不會擠壓的保證。判斷式（換算錯 vs 分配錯）與三層修法（設計層空間競爭規格 / 實作層 flex 顯式決策 / 驗證層窄幕檢查）見 <a href="/blog/report/proportional-scaling-is-not-space-allocation/" data-link-title="等比縮放不管空間分配：用了 sizing 套件、版面仍會擠壓" data-link-desc="用了響應式 / 等比縮放套件、版面仍然溢出或內容被壓縮，懷疑套件失效時使用。換算工具工作在常數層、空間分配發生在 layout 協商層，兩層獨立 — 工具靜默不等於該層無問題，「有用 X 套件」不構成該維度的品質保證">#228 等比縮放不管空間分配</a>。</p>
<h2 id="設計檢查清單">設計檢查清單</h2>
<p>為每個按鈕逐一確認：</p>
<ul>
<li><input disabled="" type="checkbox"> 點擊瞬間有視覺回饋（ripple / 狀態變化 / 觸覺）？</li>
<li><input disabled="" type="checkbox"> 非同步操作：按鈕進入 disabled + loading 狀態？</li>
<li><input disabled="" type="checkbox"> 非同步操作：完成後按鈕恢復 idle？</li>
<li><input disabled="" type="checkbox"> 操作結果有明確通知（成功 / 失敗 / 空）？</li>
<li><input disabled="" type="checkbox"> 失敗時提供可執行的下一步（重試 / 替代方案）？</li>
<li><input disabled="" type="checkbox"> 同步按鈕有 debounce 防連點？</li>
</ul>
<h2 id="參考來源">參考來源</h2>
<p>本篇整理的設計原則來自以下公開可用性研究與設計系統：</p>
<ul>
<li><strong>Nielsen&rsquo;s 10 Usability Heuristics</strong>（Nielsen Norman Group, 1994）— 啟發法 #1「系統狀態可見性」是回饋設計的理論基礎</li>
<li><strong>Doherty Threshold</strong>（Doherty &amp; Thadani, IBM 技術報告, 1982）— 400ms 回應時間門檻的原始研究</li>
<li><strong>Jakob Nielsen&rsquo;s Response Time Limits</strong>（1993）— 100ms / 1s / 10s 三門檻模型</li>
<li><strong>Material Design 3: Interaction States</strong>（Google, <a href="https://m3.material.io/foundations/interaction/states">m3.material.io</a>）— 按鈕互動狀態定義（loading indicator 是 M3 的獨立元件、引用見<a href="../response-time-strategy/">時間感知與回應策略</a>）</li>
<li><strong>Apple Human Interface Guidelines: Feedback</strong>（Apple Developer Documentation）— 回饋要可察覺、資訊明確並準確反映進度（本系列據此精神歸納出「即時、誠實的回應」的說法）</li>
<li><strong>Laws of UX</strong>（Jon Yablonski, <a href="https://lawsofux.com/doherty-threshold/">lawsofux.com</a>）— Doherty Threshold 的現代整理與應用案例</li>
</ul>
]]></content:encoded></item><item><title>按鈕狀態設計：一個按鈕的完整生命週期</title><link>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/button-state-design/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/button-state-design/</guid><description>&lt;h2 id="核心觀念">核心觀念&lt;/h2>
&lt;p>&lt;strong>按鈕的每種視覺狀態都傳達一種系統訊息&lt;/strong>，缺少任何一種，使用者就失去對應的判斷依據。&lt;a href="../feedback-three-layers/">三層回饋模型&lt;/a>回答「什麼時候給回饋」，本篇回答「用什麼視覺狀態給回饋」— 從無互動、游標懸停、鍵盤焦點到處理中，一顆按鈕的生命週期裡每個階段都有它要對使用者說的話。&lt;/p>
&lt;h2 id="基本狀態">基本狀態&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>傳達訊息&lt;/th>
 &lt;th>必要性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Default&lt;/td>
 &lt;td>無互動&lt;/td>
 &lt;td>「可以按我」&lt;/td>
 &lt;td>必要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hover&lt;/td>
 &lt;td>游標懸停（桌面端）&lt;/td>
 &lt;td>「這是可互動的元件」&lt;/td>
 &lt;td>桌面端必要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Active / Pressed&lt;/td>
 &lt;td>點擊中（手指按住）&lt;/td>
 &lt;td>「系統收到點擊了」&lt;/td>
 &lt;td>必要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus&lt;/td>
 &lt;td>鍵盤 Tab 導航選中&lt;/td>
 &lt;td>「目前焦點在這裡」&lt;/td>
 &lt;td>無障礙必要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Disabled&lt;/td>
 &lt;td>前置條件未滿足&lt;/td>
 &lt;td>「現在不能按」&lt;/td>
 &lt;td>必要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>非同步操作處理中&lt;/td>
 &lt;td>「正在處理，請等待」&lt;/td>
 &lt;td>非同步按鈕必要&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前五種狀態出自 NN/g 與 Material Design 的互動狀態定義；Loading 則來自非同步按鈕的回饋需求（等待指示 + 防重複提交）— 本篇把它跟五個通用狀態並列，因為缺了它、非同步按鈕的生命週期就不完整。切換型控件（toggle、segmented control）另有 selected 態 —「現在是開還是關」也是一種系統訊息；本表聚焦動作按鈕，選取態屬選擇控件設計。&lt;/p>
&lt;h3 id="default">Default&lt;/h3>
&lt;p>按鈕的初始狀態（&lt;a href="../feedback-three-layers/">三層回饋模型&lt;/a>的狀態流程圖以 &lt;code>idle&lt;/code> 標記的就是這個狀態）。設計重點：讓使用者一眼辨識「這是個按鈕」。常見設計手法包括填色背景、邊框、圓角矩形、陰影。與普通文字或裝飾元素的區別必須明確。&lt;/p>
&lt;h3 id="hover桌面端">Hover（桌面端）&lt;/h3>
&lt;p>游標移到按鈕上方時的狀態。行動裝置沒有 hover 概念（手指碰到螢幕就是點擊），這個狀態僅對桌面端（含 web）有意義。常見做法是背景色稍微加深或加上微陰影。&lt;/p>
&lt;h3 id="active--pressed">Active / Pressed&lt;/h3>
&lt;p>使用者按住按鈕瞬間的狀態。對應三層回饋模型的第一層「點擊確認」。視覺上通常表現為凹陷效果、顏色加深或縮小動畫。在觸控裝置上可搭配觸覺回饋（震動）。&lt;/p>
&lt;h3 id="focus">Focus&lt;/h3>
&lt;p>透過鍵盤 Tab 鍵導航到按鈕時的狀態。這是無障礙設計（Accessibility）的基本要求 — WCAG 2.1 要求所有可互動元素在獲得焦點時必須有可見的視覺指示。常見做法是加上明顯的邊框或外框光暈。&lt;/p>
&lt;h3 id="disabled">Disabled&lt;/h3>
&lt;p>按鈕不可操作時的狀態。設計要點：&lt;/p>
&lt;ul>
&lt;li>視覺上與可操作狀態有明確區別（通常降低透明度）&lt;/li>
&lt;li>&lt;strong>告知使用者「為什麼不能按」&lt;/strong> — 單純灰掉不夠。優先用常駐說明文字（按鈕旁的條件提示）；tooltip 只在桌面端可行 — 行動端沒有 hover、disabled 元素通常也不接收 focus，hover 觸發的提示對行動端與鍵盤使用者不可達&lt;/li>
&lt;li>disabled 與隱藏的分工：條件可由使用者滿足時用 disabled（灰掉 + 說明怎麼解鎖，讓使用者知道功能存在）；條件與使用者無關（權限不足、功能未開通且無法自行開通）時考慮隱藏 — 展示一個永遠按不了的按鈕只製造困惑&lt;/li>
&lt;/ul>
&lt;h3 id="loading">Loading&lt;/h3>
&lt;p>非同步操作處理中的狀態。對應三層回饋模型的第二層「等待指示」。設計要點：&lt;/p>
&lt;ul>
&lt;li>按鈕內容替換為 spinner 或文字變更（「送出」→「送出中&amp;hellip;」）&lt;/li>
&lt;li>按鈕自動進入 disabled（防重複提交）&lt;/li>
&lt;li>操作完成後必須恢復到 default 或顯示結果&lt;/li>
&lt;li>設定逾時：取值略大於 client / 後端 timeout，逾時後恢復並給錯誤訊息（兜底原則同&lt;a href="../feedback-three-layers/">三層回饋模型&lt;/a>畫面級的 timeout 段）&lt;/li>
&lt;/ul>
&lt;h2 id="非同步按鈕的狀態流">非同步按鈕的狀態流&lt;/h2>
&lt;p>非同步按鈕＝點擊後需要等待伺服器或 IO 回應的按鈕（送出表單、呼叫 API）；同步按鈕＝操作在本地立即完成的按鈕（導航、切換）。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">[Default] ─點擊→ [Active] ─釋放→ [Loading] ─完成→ [Default + 結果通知]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─失敗→ [Default + 錯誤訊息]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="要點">要點&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Active → Loading 的過渡&lt;/strong> 在使用者釋放點擊後立即發生（不等伺服器回應）&lt;/li>
&lt;li>&lt;strong>Loading 期間按鈕 disabled&lt;/strong> — 這是設計需求不是實作細節&lt;/li>
&lt;li>&lt;strong>Loading → Default 必須發生&lt;/strong> — 不論成功失敗，按鈕必須恢復可操作狀態&lt;/li>
&lt;li>&lt;strong>結果通知獨立於按鈕&lt;/strong> — 成功/失敗訊息用 SnackBar 或畫面更新呈現（形式選擇見&lt;a href="../notification-pattern-selection/">通知模式選擇&lt;/a>），不塞在按鈕裡&lt;/li>
&lt;/ol>
&lt;h2 id="同步按鈕的狀態流">同步按鈕的狀態流&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">[Default] ─點擊→ [Active] ─釋放→ [Default]（操作在釋放時同步完成）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同步按鈕不需要 Loading 狀態。防連點用 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/debounce/" data-link-title="Debounce（防連點）" data-link-desc="說明合併短時間內重複觸發的技巧，以及 leading-edge 與 trailing-edge 兩種執行語意在按鈕防連點上的差別">debounce&lt;/a> 處理，不用 disabled — disabled 會讓按鈕在每次點擊後閃跳灰態，而第一次點擊本來就該立即生效；「立即執行、短時間內忽略後續點擊」的執行語意，&lt;a href="../feedback-three-layers/">三層回饋模型&lt;/a>的防連點段有完整說明。&lt;/p>
&lt;h2 id="落點設計系統元件層">落點：設計系統元件層&lt;/h2>
&lt;p>這些狀態的實作落點在共用 Button 元件層，不是逐顆按鈕。Material、Cupertino 等平台元件庫已內建 hover / pressed / focus 的視覺處理，真正要補的通常只有 Loading 與 Disabled 的語意 — 何時進入、何時恢復、disabled 原因怎麼給。估工時以「元件層一次工 + 各按鈕接上狀態來源」計算，而非「按鈕數 × 狀態數」— 後者會把成本高估數倍，讓完整的狀態設計看起來做不起。&lt;/p>
&lt;h2 id="元件語意與版面">元件語意與版面&lt;/h2>
&lt;p>狀態清單之外，按鈕的文字、圖示、選中視覺與所在的版面各自承載語意，常見缺口：&lt;/p>
&lt;p>&lt;strong>切換元件的標籤要能區分「現態」與「動作」&lt;/strong>。「顯示目標模式」（播放鍵顯示「播放」）和「顯示當前模式」（開關顯示現態）是兩種並存慣例，單顆文字按鈕無法自證是哪種 — 名詞標籤（「管理模式」）特別容易被讀成狀態指示。消歧做法：狀態顯示與動作按鈕分離（非互動的現態文字 +「切換模式」動作鈕）、或改用自帶 on/off 語意的控件（switch / segmented control）。不幫助消歧的圖示是雜訊、應移除（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/toggle-button-label-state-ambiguity/" data-link-title="U.C15 切換按鈕顯示目標模式被讀成當前狀態 — 標籤語意歧義" data-link-desc="模式切換按鈕的文字被使用者讀成「現在的狀態」而非「按了會去哪」— 單顆文字按鈕無法自證標籤是現態還是目標，歧義是結構性的，解法是把狀態顯示與切換動作的責任拆開">U.C15&lt;/a>）。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心觀念">核心觀念</h2>
<p><strong>按鈕的每種視覺狀態都傳達一種系統訊息</strong>，缺少任何一種，使用者就失去對應的判斷依據。<a href="../feedback-three-layers/">三層回饋模型</a>回答「什麼時候給回饋」，本篇回答「用什麼視覺狀態給回饋」— 從無互動、游標懸停、鍵盤焦點到處理中，一顆按鈕的生命週期裡每個階段都有它要對使用者說的話。</p>
<h2 id="基本狀態">基本狀態</h2>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>觸發條件</th>
          <th>傳達訊息</th>
          <th>必要性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Default</td>
          <td>無互動</td>
          <td>「可以按我」</td>
          <td>必要</td>
      </tr>
      <tr>
          <td>Hover</td>
          <td>游標懸停（桌面端）</td>
          <td>「這是可互動的元件」</td>
          <td>桌面端必要</td>
      </tr>
      <tr>
          <td>Active / Pressed</td>
          <td>點擊中（手指按住）</td>
          <td>「系統收到點擊了」</td>
          <td>必要</td>
      </tr>
      <tr>
          <td>Focus</td>
          <td>鍵盤 Tab 導航選中</td>
          <td>「目前焦點在這裡」</td>
          <td>無障礙必要</td>
      </tr>
      <tr>
          <td>Disabled</td>
          <td>前置條件未滿足</td>
          <td>「現在不能按」</td>
          <td>必要</td>
      </tr>
      <tr>
          <td>Loading</td>
          <td>非同步操作處理中</td>
          <td>「正在處理，請等待」</td>
          <td>非同步按鈕必要</td>
      </tr>
  </tbody>
</table>
<p>前五種狀態出自 NN/g 與 Material Design 的互動狀態定義；Loading 則來自非同步按鈕的回饋需求（等待指示 + 防重複提交）— 本篇把它跟五個通用狀態並列，因為缺了它、非同步按鈕的生命週期就不完整。切換型控件（toggle、segmented control）另有 selected 態 —「現在是開還是關」也是一種系統訊息；本表聚焦動作按鈕，選取態屬選擇控件設計。</p>
<h3 id="default">Default</h3>
<p>按鈕的初始狀態（<a href="../feedback-three-layers/">三層回饋模型</a>的狀態流程圖以 <code>idle</code> 標記的就是這個狀態）。設計重點：讓使用者一眼辨識「這是個按鈕」。常見設計手法包括填色背景、邊框、圓角矩形、陰影。與普通文字或裝飾元素的區別必須明確。</p>
<h3 id="hover桌面端">Hover（桌面端）</h3>
<p>游標移到按鈕上方時的狀態。行動裝置沒有 hover 概念（手指碰到螢幕就是點擊），這個狀態僅對桌面端（含 web）有意義。常見做法是背景色稍微加深或加上微陰影。</p>
<h3 id="active--pressed">Active / Pressed</h3>
<p>使用者按住按鈕瞬間的狀態。對應三層回饋模型的第一層「點擊確認」。視覺上通常表現為凹陷效果、顏色加深或縮小動畫。在觸控裝置上可搭配觸覺回饋（震動）。</p>
<h3 id="focus">Focus</h3>
<p>透過鍵盤 Tab 鍵導航到按鈕時的狀態。這是無障礙設計（Accessibility）的基本要求 — WCAG 2.1 要求所有可互動元素在獲得焦點時必須有可見的視覺指示。常見做法是加上明顯的邊框或外框光暈。</p>
<h3 id="disabled">Disabled</h3>
<p>按鈕不可操作時的狀態。設計要點：</p>
<ul>
<li>視覺上與可操作狀態有明確區別（通常降低透明度）</li>
<li><strong>告知使用者「為什麼不能按」</strong> — 單純灰掉不夠。優先用常駐說明文字（按鈕旁的條件提示）；tooltip 只在桌面端可行 — 行動端沒有 hover、disabled 元素通常也不接收 focus，hover 觸發的提示對行動端與鍵盤使用者不可達</li>
<li>disabled 與隱藏的分工：條件可由使用者滿足時用 disabled（灰掉 + 說明怎麼解鎖，讓使用者知道功能存在）；條件與使用者無關（權限不足、功能未開通且無法自行開通）時考慮隱藏 — 展示一個永遠按不了的按鈕只製造困惑</li>
</ul>
<h3 id="loading">Loading</h3>
<p>非同步操作處理中的狀態。對應三層回饋模型的第二層「等待指示」。設計要點：</p>
<ul>
<li>按鈕內容替換為 spinner 或文字變更（「送出」→「送出中&hellip;」）</li>
<li>按鈕自動進入 disabled（防重複提交）</li>
<li>操作完成後必須恢復到 default 或顯示結果</li>
<li>設定逾時：取值略大於 client / 後端 timeout，逾時後恢復並給錯誤訊息（兜底原則同<a href="../feedback-three-layers/">三層回饋模型</a>畫面級的 timeout 段）</li>
</ul>
<h2 id="非同步按鈕的狀態流">非同步按鈕的狀態流</h2>
<p>非同步按鈕＝點擊後需要等待伺服器或 IO 回應的按鈕（送出表單、呼叫 API）；同步按鈕＝操作在本地立即完成的按鈕（導航、切換）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[Default] ─點擊→ [Active] ─釋放→ [Loading] ─完成→ [Default + 結果通知]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                      │
</span></span><span class="line"><span class="ln">3</span><span class="cl">                                      └─失敗→ [Default + 錯誤訊息]</span></span></code></pre></div><h3 id="要點">要點</h3>
<ol>
<li><strong>Active → Loading 的過渡</strong> 在使用者釋放點擊後立即發生（不等伺服器回應）</li>
<li><strong>Loading 期間按鈕 disabled</strong> — 這是設計需求不是實作細節</li>
<li><strong>Loading → Default 必須發生</strong> — 不論成功失敗，按鈕必須恢復可操作狀態</li>
<li><strong>結果通知獨立於按鈕</strong> — 成功/失敗訊息用 SnackBar 或畫面更新呈現（形式選擇見<a href="../notification-pattern-selection/">通知模式選擇</a>），不塞在按鈕裡</li>
</ol>
<h2 id="同步按鈕的狀態流">同步按鈕的狀態流</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[Default] ─點擊→ [Active] ─釋放→ [Default]（操作在釋放時同步完成）</span></span></code></pre></div><p>同步按鈕不需要 Loading 狀態。防連點用 <a href="/blog/ux-design/knowledge-cards/debounce/" data-link-title="Debounce（防連點）" data-link-desc="說明合併短時間內重複觸發的技巧，以及 leading-edge 與 trailing-edge 兩種執行語意在按鈕防連點上的差別">debounce</a> 處理，不用 disabled — disabled 會讓按鈕在每次點擊後閃跳灰態，而第一次點擊本來就該立即生效；「立即執行、短時間內忽略後續點擊」的執行語意，<a href="../feedback-three-layers/">三層回饋模型</a>的防連點段有完整說明。</p>
<h2 id="落點設計系統元件層">落點：設計系統元件層</h2>
<p>這些狀態的實作落點在共用 Button 元件層，不是逐顆按鈕。Material、Cupertino 等平台元件庫已內建 hover / pressed / focus 的視覺處理，真正要補的通常只有 Loading 與 Disabled 的語意 — 何時進入、何時恢復、disabled 原因怎麼給。估工時以「元件層一次工 + 各按鈕接上狀態來源」計算，而非「按鈕數 × 狀態數」— 後者會把成本高估數倍，讓完整的狀態設計看起來做不起。</p>
<h2 id="元件語意與版面">元件語意與版面</h2>
<p>狀態清單之外，按鈕的文字、圖示、選中視覺與所在的版面各自承載語意，常見缺口：</p>
<p><strong>切換元件的標籤要能區分「現態」與「動作」</strong>。「顯示目標模式」（播放鍵顯示「播放」）和「顯示當前模式」（開關顯示現態）是兩種並存慣例，單顆文字按鈕無法自證是哪種 — 名詞標籤（「管理模式」）特別容易被讀成狀態指示。消歧做法：狀態顯示與動作按鈕分離（非互動的現態文字 +「切換模式」動作鈕）、或改用自帶 on/off 語意的控件（switch / segmented control）。不幫助消歧的圖示是雜訊、應移除（<a href="/blog/ux-design/cases/toggle-button-label-state-ambiguity/" data-link-title="U.C15 切換按鈕顯示目標模式被讀成當前狀態 — 標籤語意歧義" data-link-desc="模式切換按鈕的文字被使用者讀成「現在的狀態」而非「按了會去哪」— 單顆文字按鈕無法自證標籤是現態還是目標，歧義是結構性的，解法是把狀態顯示與切換動作的責任拆開">U.C15</a>）。</p>
<p><strong>非互動指示不可與動作按鈕同形</strong>。狀態圖示混排在動作按鈕同列、又用近似圖形（描邊 vs 實心）時，使用者的預設是整列可點 — 點到指示的「沒反應」被讀成按鈕壞掉。描邊 / 實心差異不是可靠的互動性訊號；指示要用明顯非按鈕的形態（文字 chip、圓點）或移出動作列，同一個圖形全畫面只承載一個語意（<a href="/blog/ux-design/cases/status-icon-mistaken-for-button/" data-link-title="U.C18 狀態圖示被當成按鈕點 — 非互動指示與動作按鈕同形" data-link-desc="使用者回報「某個按鈕點了沒反應」、查程式發現那不是按鈕時使用。非互動的狀態圖示與動作按鈕同列混排且形態近似（描邊 vs 實心），使用者無法區分可點性">U.C18</a>）。</p>
<p><strong>選中態是（底色, 文字色）的成對設計</strong>。只設定選中底色、文字色走主題預設，組合對比沒有人驗證過 — 淺藍選中底疊淺色預設文字就是「選中後看不到字」的成因。對比方向成對反轉：底色淺配深字、底色深配淺字，逐狀態驗 WCAG AA（一般字級 4.5:1、大字 3:1）（<a href="/blog/ux-design/cases/selected-chip-contrast-not-paired/" data-link-title="U.C17 選中態換底色不換文字色 — 對比沒有成對設計" data-link-desc="選中的 chip / tab / 按鈕文字看不清楚：選中態是「底色 × 文字色」的成對設計，只指定其中一半、另一半走元件庫預設，組合對比從未被驗證">U.C17</a>）。</p>
<p><strong>水平排列的元件列溢出時要有捲動 <a href="/blog/ux-design/knowledge-cards/affordance/" data-link-title="Affordance（操作暗示）" data-link-desc="說明介面元素透過外觀傳達「可以對它做什麼」的訊號 — 可點、可捲、可拖的暗示與實際行為對齊時介面才可預測">affordance</a></strong>。chip 列、按鈕列超出畫面寬度時，截斷的預設讀法是「壞了」或「被旁邊的元件遮住」、不是「可以捲」— 提示手段：邊緣漸層遮罩、下一個項目部分露出、方向箭頭、捲軸指示；選項少時優先消除溢出（wrap 換行、改選單）。截斷邊緣緊貼固定按鈕會讓視覺歸因錯到那顆按鈕上 — 一條實作正確的可捲動篩選列因此在驗收被回報成「被重新整理按鈕遮蔽」（<a href="/blog/ux-design/cases/filter-chips-overflow-no-affordance/" data-link-title="U.C16 篩選列截斷被讀成遮蔽 — 水平溢出沒有捲動提示" data-link-desc="水平清單超出畫面寬度、使用者回報「被別的元件蓋住」或找不到後面的選項時使用。截斷若無 affordance（漸層、部分露出、箭頭），使用者讀到的是「壞了」不是「可以捲」">U.C16</a>）。</p>
<h2 id="常見設計失誤">常見設計失誤</h2>
<table>
  <thead>
      <tr>
          <th>失誤</th>
          <th>後果</th>
          <th>修正</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading 狀態未禁用按鈕</td>
          <td>重複提交</td>
          <td>Loading 自動帶入 disabled</td>
      </tr>
      <tr>
          <td>Disabled 沒有說明原因</td>
          <td>使用者困惑「為什麼不能按」</td>
          <td>搭配 tooltip 或條件說明</td>
      </tr>
      <tr>
          <td>Focus 狀態不可見</td>
          <td>鍵盤使用者無法導航</td>
          <td>加明顯 focus ring</td>
      </tr>
      <tr>
          <td>Active 狀態與 Default 視覺差異太小</td>
          <td>觸控裝置上看不出「有按到」</td>
          <td>加大視覺差異或加觸覺回饋</td>
      </tr>
      <tr>
          <td>按鈕長期 Loading 無超時機制</td>
          <td>介面永久凍結</td>
          <td>設定逾時，逾時後恢復 + 錯誤訊息</td>
      </tr>
  </tbody>
</table>
<h2 id="設計檢查清單">設計檢查清單</h2>
<p>逐一確認每個按鈕：</p>
<ul>
<li><input disabled="" type="checkbox"> 有 Default 狀態且明確可辨識為按鈕？</li>
<li><input disabled="" type="checkbox"> 桌面端有 Hover 狀態？</li>
<li><input disabled="" type="checkbox"> 有 Active / Pressed 視覺回饋？</li>
<li><input disabled="" type="checkbox"> 有可見的 Focus 狀態（鍵盤導航）？</li>
<li><input disabled="" type="checkbox"> Disabled 狀態有說明原因？</li>
<li><input disabled="" type="checkbox"> 非同步按鈕有 Loading 狀態？</li>
<li><input disabled="" type="checkbox"> Loading 期間按鈕 disabled？</li>
<li><input disabled="" type="checkbox"> Loading 有超時機制（不會永久卡住）？</li>
<li><input disabled="" type="checkbox"> Loading 結束後恢復可操作狀態？</li>
<li><input disabled="" type="checkbox"> 切換型按鈕的文字讀起來是動作而非現態（或已拆成狀態顯示 + 動作按鈕）？</li>
<li><input disabled="" type="checkbox"> 同列的非互動指示與動作按鈕形態可區分、圖示無撞名？</li>
<li><input disabled="" type="checkbox"> Selected 態的底色與文字色成對設計、對比達 WCAG AA？</li>
</ul>
<h2 id="參考來源">參考來源</h2>
<ul>
<li><strong>Nielsen Norman Group: &ldquo;Button States: Communicate Interaction&rdquo;</strong> — 按鈕各狀態如何傳達互動性的使用性研究</li>
<li><strong>Material Design 3: States</strong>（<a href="https://m3.material.io/foundations/interaction/states">m3.material.io/foundations/interaction/states</a>）— Google 設計系統的互動狀態定義</li>
<li><strong>WCAG 2.1 Success Criterion 2.4.7: Focus Visible</strong> — 焦點狀態可見性的無障礙標準</li>
<li><strong>Apple Human Interface Guidelines: Buttons</strong> — Apple 的按鈕設計原則</li>
</ul>
]]></content:encoded></item><item><title>Debounce（防連點）</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/debounce/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/debounce/</guid><description>&lt;p>Debounce 的核心概念是「短時間內的重複觸發只算一次」。同一顆按鈕在 300ms 內被點三次，系統只執行一次操作 — 剩下兩次點擊被視為誤觸或焦慮性連點而忽略。它跟 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold&lt;/a> 同屬互動回饋的時間參數：前者管「重複輸入怎麼收斂」、後者管「輸出多快要回來」。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Debounce 站在互動回饋的輸入端：&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/touch-target/" data-link-title="Touch Target（觸控目標）" data-link-desc="實機測試點列表行文字卻無反應、或可用性測試觀察到使用者重複點擊同一行時使用。觸控目標有兩層要求：尺寸不小於平台底線、範圍涵蓋視覺暗示的可點區域——後者在列表行展開/收合場景最常被忽略。">Touch Target&lt;/a> 管點擊有沒有落在可反應的區域、debounce 管落進來的重複點擊怎麼收斂、&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold&lt;/a> 管收斂後的輸出多快要回來——三張卡沿著「一次點擊的生命週期」排開。它的守備範圍是同步按鈕（導航、切換）；非同步按鈕的重複提交由 loading + disabled 狀態防守，兩者分工見可觀察訊號段。&lt;/p>
&lt;h2 id="兩種執行語意">兩種執行語意&lt;/h2>
&lt;p>同一個 debounce 週期有兩個可執行的時間點，選錯會直接改變使用者體感：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Leading-edge（立即執行）&lt;/strong>：第一次點擊立即生效，之後一段時間內的點擊忽略。按鈕防連點要用這種 — 第一次點擊本來就該立即回應。&lt;/li>
&lt;li>&lt;strong>Trailing-edge（延遲執行）&lt;/strong>：等一段時間內沒有新觸發才執行。適合搜尋框輸入這類「等使用者打完字再送查詢」的場景；用在按鈕上會給每次操作加上等待週期的延遲，違反點擊確認的 100ms 即時門檻。&lt;/li>
&lt;/ul>
&lt;p>導航鎖（in-flight flag）是按鈕防連點的替代做法：操作進行中拒絕新請求、完成後解鎖，不依賴固定時間窗。&lt;/p>
&lt;p>Throttle 是近親：固定時間窗內至多執行一次、但持續觸發會持續執行（如捲動事件每 200ms 取樣一次）；debounce 則把連續觸發收斂成一次。防連點用 debounce，高頻連續事件的節流用 throttle。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 debounce 的訊號是同一個操作被觸發多次的紀錄：導航堆疊被 push 兩層一樣的頁面、後端收到毫秒級間隔的重複請求、表單被建立兩筆相同資料。非同步按鈕通常用 Loading + disabled 防重複提交，debounce 主要負責同步按鈕（導航、切換）— 這些按鈕操作瞬間完成、沒有 loading 狀態可以擋。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Debounce 的設計責任是在不犧牲第一次點擊即時性的前提下吸收重複觸發。時間窗常見慣例值是 300ms 上下，依操作型態調整。完整的防連點決策（哪類按鈕用 debounce、哪類用 disabled）見&lt;a href="https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/feedback-three-layers/" data-link-title="互動回饋三層模型：點擊確認、等待指示、結果通知" data-link-desc="使用者操作後的回饋依時間分層，缺層的症狀是重複提交與重複導航 — 診斷「按了沒反應」與多步驟流程卡狀態問題的檢查框架，涵蓋按鈕級與畫面級兩個尺度。">互動回饋三層模型&lt;/a>的兩類按鈕段。&lt;/p></description><content:encoded><![CDATA[<p>Debounce 的核心概念是「短時間內的重複觸發只算一次」。同一顆按鈕在 300ms 內被點三次，系統只執行一次操作 — 剩下兩次點擊被視為誤觸或焦慮性連點而忽略。它跟 <a href="/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold</a> 同屬互動回饋的時間參數：前者管「重複輸入怎麼收斂」、後者管「輸出多快要回來」。</p>
<h2 id="概念位置">概念位置</h2>
<p>Debounce 站在互動回饋的輸入端：<a href="/blog/ux-design/knowledge-cards/touch-target/" data-link-title="Touch Target（觸控目標）" data-link-desc="實機測試點列表行文字卻無反應、或可用性測試觀察到使用者重複點擊同一行時使用。觸控目標有兩層要求：尺寸不小於平台底線、範圍涵蓋視覺暗示的可點區域——後者在列表行展開/收合場景最常被忽略。">Touch Target</a> 管點擊有沒有落在可反應的區域、debounce 管落進來的重複點擊怎麼收斂、<a href="/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold</a> 管收斂後的輸出多快要回來——三張卡沿著「一次點擊的生命週期」排開。它的守備範圍是同步按鈕（導航、切換）；非同步按鈕的重複提交由 loading + disabled 狀態防守，兩者分工見可觀察訊號段。</p>
<h2 id="兩種執行語意">兩種執行語意</h2>
<p>同一個 debounce 週期有兩個可執行的時間點，選錯會直接改變使用者體感：</p>
<ul>
<li><strong>Leading-edge（立即執行）</strong>：第一次點擊立即生效，之後一段時間內的點擊忽略。按鈕防連點要用這種 — 第一次點擊本來就該立即回應。</li>
<li><strong>Trailing-edge（延遲執行）</strong>：等一段時間內沒有新觸發才執行。適合搜尋框輸入這類「等使用者打完字再送查詢」的場景；用在按鈕上會給每次操作加上等待週期的延遲，違反點擊確認的 100ms 即時門檻。</li>
</ul>
<p>導航鎖（in-flight flag）是按鈕防連點的替代做法：操作進行中拒絕新請求、完成後解鎖，不依賴固定時間窗。</p>
<p>Throttle 是近親：固定時間窗內至多執行一次、但持續觸發會持續執行（如捲動事件每 200ms 取樣一次）；debounce 則把連續觸發收斂成一次。防連點用 debounce，高頻連續事件的節流用 throttle。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 debounce 的訊號是同一個操作被觸發多次的紀錄：導航堆疊被 push 兩層一樣的頁面、後端收到毫秒級間隔的重複請求、表單被建立兩筆相同資料。非同步按鈕通常用 Loading + disabled 防重複提交，debounce 主要負責同步按鈕（導航、切換）— 這些按鈕操作瞬間完成、沒有 loading 狀態可以擋。</p>
<h2 id="設計責任">設計責任</h2>
<p>Debounce 的設計責任是在不犧牲第一次點擊即時性的前提下吸收重複觸發。時間窗常見慣例值是 300ms 上下，依操作型態調整。完整的防連點決策（哪類按鈕用 debounce、哪類用 disabled）見<a href="/blog/ux-design/06-interaction-feedback/feedback-three-layers/" data-link-title="互動回饋三層模型：點擊確認、等待指示、結果通知" data-link-desc="使用者操作後的回饋依時間分層，缺層的症狀是重複提交與重複導航 — 診斷「按了沒反應」與多步驟流程卡狀態問題的檢查框架，涵蓋按鈕級與畫面級兩個尺度。">互動回饋三層模型</a>的兩類按鈕段。</p>
]]></content:encoded></item><item><title>U.C5 匯出按鈕按下零回饋 — 狀態機完備但 UI 沒接線</title><link>https://tarrragon.github.io/blog/ux-design/cases/export-button-zero-feedback/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/export-button-zero-feedback/</guid><description>&lt;p>book_overview_app 是一個 Flutter 書庫管理教學專案，以下案例取自該專案的實機測試。按鈕背後的業務邏輯與狀態機完全正確，但三層回饋（點擊確認 / 等待指示 / 結果通知）全缺時，使用者的體感等同功能壞掉。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>book_overview_app 的匯出設定頁有一顆「確認匯出」按鈕。實機測試按下後畫面無任何變化：沒有 loading、沒有成功訊息、沒有錯誤訊息。使用者無法判斷三種可能中的哪一種成立：匯出已完成、匯出正在執行、按鈕根本沒接線。&lt;/p>
&lt;p>翻開程式碼，真相是第三種 — &lt;code>onPressed&lt;/code> 是一個空 callback，裡面只有一行 &lt;code>// 導航到進度頁面&lt;/code> 註解。而 ViewModel 層的 &lt;code>startExport()&lt;/code> 早已具備 idle / inProgress / completed / failed 的完整狀態轉換邏輯，頁面從未呼叫它。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>回饋層&lt;/th>
 &lt;th>修復前&lt;/th>
 &lt;th>修復後（W1-086）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>點擊確認&lt;/td>
 &lt;td>無（空 callback）&lt;/td>
 &lt;td>&lt;code>onPressed&lt;/code> 接線 &lt;code>viewModel.startExport&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>等待指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;code>inProgress&lt;/code> 時按鈕禁用 + &lt;code>CircularProgressIndicator&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結果通知&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;code>ref.listen&lt;/code> 監聽狀態：成功彈對話框含檔案路徑；失敗彈對話框含原因與重試按鈕&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>修復只改一個檔案（匯出設定頁），ViewModel 與狀態定義一行未動。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>三層回饋全缺時，「正確的後端」對使用者不存在&lt;/strong>。回饋是使用者感知系統狀態的唯一通道。點擊確認回答「系統收到了嗎」、等待指示回答「還在跑嗎」、結果通知回答「成功了嗎、檔案在哪」。三層全缺時，使用者只能靠猜 — 而猜的預設答案是「壞掉了」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>缺口在接線、設計本身完備&lt;/strong>。狀態機四個狀態齊全、&lt;code>retryExport()&lt;/code> 都寫好了。缺口是 UI 層留了一行 TODO 式註解就交付。domain 層完備反而讓缺口更隱蔽：單元測試全綠（ViewModel 邏輯正確），widget 測試只斷言畫面渲染，沒有斷言「按下後狀態轉換」，測試體系抓不到這種缺口。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&lt;code>onPressed: () {}&lt;/code> 是可機械掃描的訊號&lt;/strong>。空 callback、只含註解的 callback，跟寫了函式沒有呼叫者一樣，是「UI 死路」的字面特徵，grep 就能找到，不需要等實機測試。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>三層回饋條款化為驗收標準&lt;/strong>：每顆觸發非同步操作的按鈕，驗收清單固定三問 — 點擊有確認？等待有指示？結果有通知？本案修復同時把這三層寫進規格（FR-8 補「按鈕層級三層回饋」條款，引用 100ms/400ms/1s 時間門檻），讓後續頁面在設計階段就對照。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>接線完整性掃描&lt;/strong>：交付前 grep &lt;code>onPressed: () {}&lt;/code>、&lt;code>onPressed: null&lt;/code>（非刻意禁用場景）與只含註解的 callback。UI 接線遺漏跟死程式碼一樣可機械偵測。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>widget 測試必含行為斷言&lt;/strong>：每顆按鈕至少一個「tap 後斷言狀態轉換或副作用」的測試，不能只有「按鈕存在且可見」的渲染斷言。本案的 RED 測試（按下確認匯出後狀態非 idle）正是修復前缺失的那一個。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>三層回饋模型的完整定義 → &lt;a href="https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/feedback-three-layers/" data-link-title="互動回饋三層模型：點擊確認、等待指示、結果通知" data-link-desc="使用者操作後的回饋依時間分層，缺層的症狀是重複提交與重複導航 — 診斷「按了沒反應」與多步驟流程卡狀態問題的檢查框架，涵蓋按鈕級與畫面級兩個尺度。">互動回饋三層模型&lt;/a>&lt;/li>
&lt;li>非同步按鈕的生命週期設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/button-state-design/" data-link-title="按鈕狀態設計：一個按鈕的完整生命週期" data-link-desc="按鈕每種視覺狀態各傳達一種系統訊息 — 設計按鈕互動、無障礙焦點與非同步回饋時的狀態清單依據。">按鈕狀態設計&lt;/a>&lt;/li>
&lt;li>等待指示的時間門檻 → &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold&lt;/a>&lt;/li>
&lt;li>類似案例（回饋誠實但誤導）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/misleading-no-result-for-product-barcode/" data-link-title="U.C7 商品條碼的誤導性查無結果 — 可本地判定的錯誤讓使用者白等 API" data-link-desc="Flutter app 的 ISBN 掃描器接受所有 EAN-13 條碼，掃到一般商品條碼（非 978/979 開頭）時送 API 查詢，2 秒後回「查無結果」— 訊息誠實但誤導，使用者以為書找不到，實際上是掃錯了條碼。正確回饋是本地立即判定「這不是書籍條碼」">U.C7 商品條碼的誤導性查無結果&lt;/a>&lt;/li>
&lt;li>未接線的另一種形態（刻意佔位）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/management-actions-placeholder-only/" data-link-title="U.C20 管理模式操作全是佔位 — dev toast 讓未接線看起來有反應" data-link-desc="UI 有按鈕、domain 層功能也寫完、按下去只有開發提示或 log：佔位 handler 比沒有按鈕更糟 — 按鈕的存在承諾功能存在，dev toast 讓開發自測「有反應」、掩蓋未接線">U.C20 管理模式操作全是佔位&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>book_overview_app 是一個 Flutter 書庫管理教學專案，以下案例取自該專案的實機測試。按鈕背後的業務邏輯與狀態機完全正確，但三層回饋（點擊確認 / 等待指示 / 結果通知）全缺時，使用者的體感等同功能壞掉。</p>
<h2 id="觀察">觀察</h2>
<p>book_overview_app 的匯出設定頁有一顆「確認匯出」按鈕。實機測試按下後畫面無任何變化：沒有 loading、沒有成功訊息、沒有錯誤訊息。使用者無法判斷三種可能中的哪一種成立：匯出已完成、匯出正在執行、按鈕根本沒接線。</p>
<p>翻開程式碼，真相是第三種 — <code>onPressed</code> 是一個空 callback，裡面只有一行 <code>// 導航到進度頁面</code> 註解。而 ViewModel 層的 <code>startExport()</code> 早已具備 idle / inProgress / completed / failed 的完整狀態轉換邏輯，頁面從未呼叫它。</p>
<table>
  <thead>
      <tr>
          <th>回饋層</th>
          <th>修復前</th>
          <th>修復後（W1-086）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>點擊確認</td>
          <td>無（空 callback）</td>
          <td><code>onPressed</code> 接線 <code>viewModel.startExport</code></td>
      </tr>
      <tr>
          <td>等待指示</td>
          <td>無</td>
          <td><code>inProgress</code> 時按鈕禁用 + <code>CircularProgressIndicator</code></td>
      </tr>
      <tr>
          <td>結果通知</td>
          <td>無</td>
          <td><code>ref.listen</code> 監聽狀態：成功彈對話框含檔案路徑；失敗彈對話框含原因與重試按鈕</td>
      </tr>
  </tbody>
</table>
<p>修復只改一個檔案（匯出設定頁），ViewModel 與狀態定義一行未動。</p>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>三層回饋全缺時，「正確的後端」對使用者不存在</strong>。回饋是使用者感知系統狀態的唯一通道。點擊確認回答「系統收到了嗎」、等待指示回答「還在跑嗎」、結果通知回答「成功了嗎、檔案在哪」。三層全缺時，使用者只能靠猜 — 而猜的預設答案是「壞掉了」。</p>
</li>
<li>
<p><strong>缺口在接線、設計本身完備</strong>。狀態機四個狀態齊全、<code>retryExport()</code> 都寫好了。缺口是 UI 層留了一行 TODO 式註解就交付。domain 層完備反而讓缺口更隱蔽：單元測試全綠（ViewModel 邏輯正確），widget 測試只斷言畫面渲染，沒有斷言「按下後狀態轉換」，測試體系抓不到這種缺口。</p>
</li>
<li>
<p><strong><code>onPressed: () {}</code> 是可機械掃描的訊號</strong>。空 callback、只含註解的 callback，跟寫了函式沒有呼叫者一樣，是「UI 死路」的字面特徵，grep 就能找到，不需要等實機測試。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li>
<p><strong>三層回饋條款化為驗收標準</strong>：每顆觸發非同步操作的按鈕，驗收清單固定三問 — 點擊有確認？等待有指示？結果有通知？本案修復同時把這三層寫進規格（FR-8 補「按鈕層級三層回饋」條款，引用 100ms/400ms/1s 時間門檻），讓後續頁面在設計階段就對照。</p>
</li>
<li>
<p><strong>接線完整性掃描</strong>：交付前 grep <code>onPressed: () {}</code>、<code>onPressed: null</code>（非刻意禁用場景）與只含註解的 callback。UI 接線遺漏跟死程式碼一樣可機械偵測。</p>
</li>
<li>
<p><strong>widget 測試必含行為斷言</strong>：每顆按鈕至少一個「tap 後斷言狀態轉換或副作用」的測試，不能只有「按鈕存在且可見」的渲染斷言。本案的 RED 測試（按下確認匯出後狀態非 idle）正是修復前缺失的那一個。</p>
</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>三層回饋模型的完整定義 → <a href="/blog/ux-design/06-interaction-feedback/feedback-three-layers/" data-link-title="互動回饋三層模型：點擊確認、等待指示、結果通知" data-link-desc="使用者操作後的回饋依時間分層，缺層的症狀是重複提交與重複導航 — 診斷「按了沒反應」與多步驟流程卡狀態問題的檢查框架，涵蓋按鈕級與畫面級兩個尺度。">互動回饋三層模型</a></li>
<li>非同步按鈕的生命週期設計 → <a href="/blog/ux-design/06-interaction-feedback/button-state-design/" data-link-title="按鈕狀態設計：一個按鈕的完整生命週期" data-link-desc="按鈕每種視覺狀態各傳達一種系統訊息 — 設計按鈕互動、無障礙焦點與非同步回饋時的狀態清單依據。">按鈕狀態設計</a></li>
<li>等待指示的時間門檻 → <a href="/blog/ux-design/knowledge-cards/doherty-threshold/" data-link-title="Doherty Threshold（400ms 門檻）" data-link-desc="說明 400ms 回應時間門檻的出身（IBM 生產力研究）、現代設計慣例的轉譯過程，以及它跟 Nielsen 感知門檻的量測差異">Doherty Threshold</a></li>
<li>類似案例（回饋誠實但誤導）→ <a href="/blog/ux-design/cases/misleading-no-result-for-product-barcode/" data-link-title="U.C7 商品條碼的誤導性查無結果 — 可本地判定的錯誤讓使用者白等 API" data-link-desc="Flutter app 的 ISBN 掃描器接受所有 EAN-13 條碼，掃到一般商品條碼（非 978/979 開頭）時送 API 查詢，2 秒後回「查無結果」— 訊息誠實但誤導，使用者以為書找不到，實際上是掃錯了條碼。正確回饋是本地立即判定「這不是書籍條碼」">U.C7 商品條碼的誤導性查無結果</a></li>
<li>未接線的另一種形態（刻意佔位）→ <a href="/blog/ux-design/cases/management-actions-placeholder-only/" data-link-title="U.C20 管理模式操作全是佔位 — dev toast 讓未接線看起來有反應" data-link-desc="UI 有按鈕、domain 層功能也寫完、按下去只有開發提示或 log：佔位 handler 比沒有按鈕更糟 — 按鈕的存在承諾功能存在，dev toast 讓開發自測「有反應」、掩蓋未接線">U.C20 管理模式操作全是佔位</a></li>
</ul>
]]></content:encoded></item><item><title>模組六：互動回饋設計</title><link>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ux-design/06-interaction-feedback/</guid><description>&lt;p>回答「使用者按了按鈕之後發生什麼事」。核心原則：&lt;strong>每個使用者動作都需要即時、誠實的系統回應&lt;/strong> — 沒有回饋的按鈕等同壞掉的按鈕。&lt;/p>
&lt;h2 id="設計問題">設計問題&lt;/h2>
&lt;p>使用者點擊按鈕後，有三個時間點需要回饋：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>點擊瞬間&lt;/strong> — 系統收到操作了嗎？（點擊確認）&lt;/li>
&lt;li>&lt;strong>等待期間&lt;/strong> — 系統在處理嗎？（等待指示）&lt;/li>
&lt;li>&lt;strong>操作完成&lt;/strong> — 結果是什麼？（結果通知：成功 / 失敗 / 空結果）&lt;/li>
&lt;/ol>
&lt;p>缺少任何一層，使用者的反應都是「再按一次」— 導致重複提交、重複導航、重複請求。&lt;/p>
&lt;p>「按了沒反應」在案例庫有四種被驗證過的成因，診斷時逐一排除：回饋未接線（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/export-button-zero-feedback/" data-link-title="U.C5 匯出按鈕按下零回饋 — 狀態機完備但 UI 沒接線" data-link-desc="Flutter app 匯出設定頁的確認匯出按鈕 onPressed 是空 callback，按下畫面毫無變化 — 使用者無法分辨匯出成功、進行中、還是功能根本沒做。ViewModel 的 idle/inProgress/completed/failed 狀態機早已完備，缺的只是頁面接線與三層回饋">U.C5&lt;/a>）、佔位 handler（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/management-actions-placeholder-only/" data-link-title="U.C20 管理模式操作全是佔位 — dev toast 讓未接線看起來有反應" data-link-desc="UI 有按鈕、domain 層功能也寫完、按下去只有開發提示或 log：佔位 handler 比沒有按鈕更糟 — 按鈕的存在承諾功能存在，dev toast 讓開發自測「有反應」、掩蓋未接線">U.C20&lt;/a>）、點到的不是按鈕（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/status-icon-mistaken-for-button/" data-link-title="U.C18 狀態圖示被當成按鈕點 — 非互動指示與動作按鈕同形" data-link-desc="使用者回報「某個按鈕點了沒反應」、查程式發現那不是按鈕時使用。非互動的狀態圖示與動作按鈕同列混排且形態近似（描邊 vs 實心），使用者無法區分可點性">U.C18&lt;/a>）、回饋被版面壓縮（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/selection-count-layout-starvation/" data-link-title="U.C19 「已選擇」計數被版面擠壓成省略號 — 回饋死在 layout" data-link-desc="狀態文字顯示成「...」、資料層查起來卻一切正常 — 回饋鏈的最後一哩是版面，flex 寬度競爭把關鍵計數整串壓成省略號，state 正確、使用者拿到零資訊">U.C19&lt;/a>）。&lt;/p>
&lt;h2 id="章節">章節&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>回答問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="feedback-three-layers/">互動回饋三層模型&lt;/a>&lt;/td>
 &lt;td>三層模型 + 畫面級狀態轉換&lt;/td>
 &lt;td>使用者按了按鈕為什麼沒反應？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="button-state-design/">按鈕狀態設計&lt;/a>&lt;/td>
 &lt;td>按鈕狀態 + 元件語意與版面（標籤 / 對比 / 溢出）&lt;/td>
 &lt;td>一個按鈕需要幾種狀態？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="response-time-strategy/">時間感知與回應策略&lt;/a>&lt;/td>
 &lt;td>等待時間的回饋策略 + 感知效能技巧&lt;/td>
 &lt;td>什麼時候該顯示 Loading？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="notification-pattern-selection/">通知模式選擇&lt;/a>&lt;/td>
 &lt;td>SnackBar / Dialog / Banner / Bottom Sheet 判準&lt;/td>
 &lt;td>結果通知該用什麼形式呈現？&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每篇文末附設計檢查清單，可直接作為 code review 與規格驗收的依據。&lt;/p>
&lt;h2 id="模組邊界">模組邊界&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>放在本模組&lt;/th>
 &lt;th>放在其他模組&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>按鈕點擊回饋（視覺 + 觸覺）&lt;/td>
 &lt;td>畫面狀態機設計（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">模組一&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>非同步操作等待指示（loading / spinner）&lt;/td>
 &lt;td>錯誤訊息撰寫原則（&lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/" data-link-title="模組四：錯誤狀態與回復" data-link-desc="錯誤不只是紅色文字 — 是一個需要設計退出路徑的狀態">模組四&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>操作結果通知（成功 / 失敗 / 空結果呈現）&lt;/td>
 &lt;td>導航模式（&lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>防重複提交（debounce 防連點 / disable）&lt;/td>
 &lt;td>表單驗證 UX（&lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">模組三&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>感知效能技巧（skeleton / 樂觀更新）&lt;/td>
 &lt;td>無障礙宣告與可聽化（aria-live / aria-busy）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<p>回答「使用者按了按鈕之後發生什麼事」。核心原則：<strong>每個使用者動作都需要即時、誠實的系統回應</strong> — 沒有回饋的按鈕等同壞掉的按鈕。</p>
<h2 id="設計問題">設計問題</h2>
<p>使用者點擊按鈕後，有三個時間點需要回饋：</p>
<ol>
<li><strong>點擊瞬間</strong> — 系統收到操作了嗎？（點擊確認）</li>
<li><strong>等待期間</strong> — 系統在處理嗎？（等待指示）</li>
<li><strong>操作完成</strong> — 結果是什麼？（結果通知：成功 / 失敗 / 空結果）</li>
</ol>
<p>缺少任何一層，使用者的反應都是「再按一次」— 導致重複提交、重複導航、重複請求。</p>
<p>「按了沒反應」在案例庫有四種被驗證過的成因，診斷時逐一排除：回饋未接線（<a href="/blog/ux-design/cases/export-button-zero-feedback/" data-link-title="U.C5 匯出按鈕按下零回饋 — 狀態機完備但 UI 沒接線" data-link-desc="Flutter app 匯出設定頁的確認匯出按鈕 onPressed 是空 callback，按下畫面毫無變化 — 使用者無法分辨匯出成功、進行中、還是功能根本沒做。ViewModel 的 idle/inProgress/completed/failed 狀態機早已完備，缺的只是頁面接線與三層回饋">U.C5</a>）、佔位 handler（<a href="/blog/ux-design/cases/management-actions-placeholder-only/" data-link-title="U.C20 管理模式操作全是佔位 — dev toast 讓未接線看起來有反應" data-link-desc="UI 有按鈕、domain 層功能也寫完、按下去只有開發提示或 log：佔位 handler 比沒有按鈕更糟 — 按鈕的存在承諾功能存在，dev toast 讓開發自測「有反應」、掩蓋未接線">U.C20</a>）、點到的不是按鈕（<a href="/blog/ux-design/cases/status-icon-mistaken-for-button/" data-link-title="U.C18 狀態圖示被當成按鈕點 — 非互動指示與動作按鈕同形" data-link-desc="使用者回報「某個按鈕點了沒反應」、查程式發現那不是按鈕時使用。非互動的狀態圖示與動作按鈕同列混排且形態近似（描邊 vs 實心），使用者無法區分可點性">U.C18</a>）、回饋被版面壓縮（<a href="/blog/ux-design/cases/selection-count-layout-starvation/" data-link-title="U.C19 「已選擇」計數被版面擠壓成省略號 — 回饋死在 layout" data-link-desc="狀態文字顯示成「...」、資料層查起來卻一切正常 — 回饋鏈的最後一哩是版面，flex 寬度競爭把關鍵計數整串壓成省略號，state 正確、使用者拿到零資訊">U.C19</a>）。</p>
<h2 id="章節">章節</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>回答問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="feedback-three-layers/">互動回饋三層模型</a></td>
          <td>三層模型 + 畫面級狀態轉換</td>
          <td>使用者按了按鈕為什麼沒反應？</td>
      </tr>
      <tr>
          <td><a href="button-state-design/">按鈕狀態設計</a></td>
          <td>按鈕狀態 + 元件語意與版面（標籤 / 對比 / 溢出）</td>
          <td>一個按鈕需要幾種狀態？</td>
      </tr>
      <tr>
          <td><a href="response-time-strategy/">時間感知與回應策略</a></td>
          <td>等待時間的回饋策略 + 感知效能技巧</td>
          <td>什麼時候該顯示 Loading？</td>
      </tr>
      <tr>
          <td><a href="notification-pattern-selection/">通知模式選擇</a></td>
          <td>SnackBar / Dialog / Banner / Bottom Sheet 判準</td>
          <td>結果通知該用什麼形式呈現？</td>
      </tr>
  </tbody>
</table>
<p>每篇文末附設計檢查清單，可直接作為 code review 與規格驗收的依據。</p>
<h2 id="模組邊界">模組邊界</h2>
<table>
  <thead>
      <tr>
          <th>放在本模組</th>
          <th>放在其他模組</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>按鈕點擊回饋（視覺 + 觸覺）</td>
          <td>畫面狀態機設計（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">模組一</a>）</td>
      </tr>
      <tr>
          <td>非同步操作等待指示（loading / spinner）</td>
          <td>錯誤訊息撰寫原則（<a href="/blog/ux-design/04-error-recovery/" data-link-title="模組四：錯誤狀態與回復" data-link-desc="錯誤不只是紅色文字 — 是一個需要設計退出路徑的狀態">模組四</a>）</td>
      </tr>
      <tr>
          <td>操作結果通知（成功 / 失敗 / 空結果呈現）</td>
          <td>導航模式（<a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五</a>）</td>
      </tr>
      <tr>
          <td>防重複提交（debounce 防連點 / disable）</td>
          <td>表單驗證 UX（<a href="/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">模組三</a>）</td>
      </tr>
      <tr>
          <td>感知效能技巧（skeleton / 樂觀更新）</td>
          <td>無障礙宣告與可聽化（aria-live / aria-busy）</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>