iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延系列 第 28

【Day 28】— 入門 JavaScript 網頁架設:動畫過場 + 拖曳高亮 + Ripple

  • 分享至 

  • xImage
  •  

摘要
讓操作「看起來就對」。本日替 showPage() 加入過場動畫(淡入/淡出、滑入)、任務卡拖曳時的高亮與壓下感,以及主要按鈕的 Ripple 波紋與按壓縮放。同時尊重使用者的 prefers-reduced-motion,在需要時自動關閉動畫。

為什麼要做動畫與微互動?

  • 方向感:頁面切換有「來與去」,不再瞬間跳轉。
  • 回饋感:拖曳至落點有亮邊與陰影、按鈕被點有回饋,提升信心。
  • 無障礙:支援「減少動態效果」,避免易敏感的使用者不適。

學習重點

  • CSS:transition、@keyframes、:hover、:active、prefers-reduced-motion
  • JS:以 class 切換 觸發 CSS 動畫;在切換頁面時用 enter/leave 流程避免被 hidden 提早打斷
  • 微互動設計:拖曳中 .dragging、落點 .dropping;按鈕 .rippling

核心流程

  1. 頁面過場:showPage(page) 在「現在頁 → 目標頁」間加入 anim-leave / anim-enter,動畫結束後才切 hidden。
  2. 拖曳強化:dragstart/dragend 給任務卡 .dragging;dragover/dragenter/dragleave/drop 補 .dropping 的視覺提示。
  3. 按鈕波紋:在主要行動按鈕上掛 ripple 事件,計算點擊座標 → 以 CSS 變數推動 @keyframes ripple。

實作

假設已具備:

  • Day 4–5:readRecords / writeRecords / addRecord
  • Day 12–19:頁面切換骨架與二訪互動
  • Day 16–18:任務安排(含拖曳)與溫暖總結
  • Day 20–21:Chart.js 圖表(分布、占比、趨勢)
  • Day 22–25:匯出、雲端備份、語錄牆
  • Day 26–27:全站樣式(CSS 變數 + 主題切換)
  1. styles.css(加在檔尾,保留原有內容)
/* ========== [Day28-NEW] 動畫變數與減動態支援 ========== */
:root {
  --anim-fast: .18s;
  --anim-normal: .32s;
  --easing: cubic-bezier(.22,.61,.36,1); /* 柔和、收尾順 */
}

/* 使用者要求「降低動態」時:關閉動畫/過場 */
@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* ========== [Day28-NEW] 頁面過場 ========== */
/* 進場:微下位移 + 淡入;退場:微上位移 + 淡出 */
@keyframes fadeSlideIn {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes fadeSlideOut {
  from { opacity: 1; transform: translateY(0); }
  to   { opacity: 0; transform: translateY(-6px); }
}

.anim-enter { animation: fadeSlideIn var(--anim-normal) var(--easing) both; }
.anim-leave { animation: fadeSlideOut var(--anim-normal) var(--easing) both; }

/* 避免 display:none 阻斷動畫:過場時暫時確保可見且不搶點擊 */
.page-is-switching { pointer-events: none; }

/* ========== [Day28-NEW] 任務卡拖曳中的視覺強化 ========== */
#taskCard.dragging {
  opacity: .92;
  transform: scale(.98);
  box-shadow: 0 8px 22px rgba(0,0,0,.15);
  transition: transform var(--anim-fast) var(--easing), box-shadow var(--anim-fast) var(--easing), opacity var(--anim-fast);
}

/* 已有 .time-slot.dropping:再加一點入場感與邊緣光暈 */
.time-slot.dropping {
  transform: translateY(-1px) scale(1.01);
  transition: transform var(--anim-fast) var(--easing), box-shadow var(--anim-fast) var(--easing);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 6px 18px rgba(0,0,0,.06);
}

/* ========== [Day28-NEW] 按鈕按壓與 Ripple ========== */
button {
  transition: transform .06s ease, background-color .2s ease, box-shadow .2s ease;
}
button:active { transform: scale(.985); }

/* 開啟 ripple 的按鈕需要相對定位與裁切 */
.ripple-btn { position: relative; overflow: hidden; }
.ripple-btn::after {
  content: "";
  position: absolute;
  left: var(--rx, 50%); top: var(--ry, 50%);
  width: 0; height: 0; border-radius: 999px;
  transform: translate(-50%, -50%);
  background: rgba(255,255,255,.35); /* 在淺底會視覺較淡,主要用於主色按鈕 */
  pointer-events: none;
  opacity: 0;
}
.rippling::after { animation: ripple .48s ease-out forwards; }

@keyframes ripple {
  0%   { width: 0; height: 0; opacity: .55; }
  100% { width: 180px; height: 180px; opacity: 0; }
}

/* 深色模式下 ripple 用較暗的亮點,避免過亮刺眼 */
body.dark .ripple-btn::after { background: rgba(255,255,255,.28); }
備註:prefers-reduced-motion 會讓整站 transition/animation 關閉;可確保敏感使用者舒適。
  1. script.js
    2-1. 拖曳強化:任務卡 .dragging 與更完整的 slot 事件
// [Day28-NEW] 任務卡拖曳中的壓下感
document.addEventListener('dragstart', (e) => {
  const el = e.target;
  if (el && el.id === 'taskCard') {
    el.classList.add('dragging');
  }
});
document.addEventListener('dragend', (e) => {
  const el = e.target;
  if (el && el.id === 'taskCard') {
    el.classList.remove('dragging');
  }
});

// [Day28-NEW] slot 加強 dragenter / dragleave(已含 dragover/drop,這裡補更流暢的亮邊控制)
[morningSlotEl, afternoonSlotEl, eveningSlotEl].forEach(slot => {
  if (!slot) return;
  slot.addEventListener('dragenter', () => slot.classList.add('dropping'));
  slot.addEventListener('dragleave', () => slot.classList.remove('dropping'));
});

2-2. Ripple 微互動:主要行動按鈕掛載

// [Day28-NEW] Ripple:將主要按鈕升級為 .ripple-btn
function attachRipple(btn) {
  if (!btn) return;
  btn.classList.add('ripple-btn');
  btn.addEventListener('click', (e) => {
    // 若使用者偏好減少動態 → 跳過
    if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    const rect = btn.getBoundingClientRect();
    const x = (e.clientX || (rect.left + rect.width/2)) - rect.left;
    const y = (e.clientY || (rect.top + rect.height/2)) - rect.top;
    btn.style.setProperty('--rx', `${x}px`);
    btn.style.setProperty('--ry', `${y}px`);
    btn.classList.remove('rippling'); // 允許連點重啟動畫
    // 強制 reflow 以重啟動畫
    void btn.offsetWidth;
    btn.classList.add('rippling');
    setTimeout(() => btn.classList.remove('rippling'), 500);
  });
}

function enhanceMicroInteractions() {
  // 針對主色行動鈕加 ripple(你也可以視覺需求擴充)
  [
    '#submitBtn', '#btnStartNow', '#btnShare',
    '#btnBackupNow', '#btnExportJSON', '#btnExportCSV', '#btnExportQuoteJSON'
  ].forEach(sel => attachRipple(document.querySelector(sel)));
}

放在你的 init() 流程內呼叫(如下 2-4 節)。

2-3. 頁面切換動畫版:enter/leave 過場(尊重 reduced-motion)

// [Day28-NEW] 找出目前顯示中的主要區塊
function getVisibleSection() {
  const sections = [formSection, historySection, moodTestSection, moodDecisionSection, taskScheduleSection].filter(Boolean);
  return sections.find(s => s.hidden === false) || null;
}

// [Day28-NEW] 以動畫切換 section(leave -> enter),動畫結束後才調整 hidden
function switchSectionWithAnimation(fromEl, toEl, afterShowCb) {
  // 無目標或同一個 → 略過
  if (!toEl || fromEl === toEl) {
    if (toEl) { toEl.hidden = false; typeof afterShowCb === 'function' && afterShowCb(); }
    return;
  }

  const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduce) {
    // 直接切換(無動畫)
    if (fromEl) fromEl.hidden = true;
    toEl.hidden = false;
    typeof afterShowCb === 'function' && afterShowCb();
    return;
  }

  // 動畫:先把目標顯示出來(以便動畫跑),再加 enter;來源加 leave,動畫結束後才 hidden
  document.body.classList.add('page-is-switching');

  if (fromEl) {
    fromEl.classList.add('anim-leave');
    fromEl.addEventListener('animationend', () => {
      fromEl.classList.remove('anim-leave');
      fromEl.hidden = true;
    }, { once: true });
  }

  toEl.hidden = false;
  toEl.classList.add('anim-enter');
  toEl.addEventListener('animationend', () => {
    toEl.classList.remove('anim-enter');
    document.body.classList.remove('page-is-switching');
    typeof afterShowCb === 'function' && afterShowCb();
  }, { once: true });
}

2-4. 修改 showPage():改成使用動畫切換
(保留你原本的功能:進歷史頁會渲染統計與語錄牆、聚焦處理…)

// 小工具:嘗試聚焦標題;若失敗,將區塊設為可聚焦並聚焦
function focusOrFallback(sectionEl, titleElId) {
  const titleEl = titleElId ? document.getElementById(titleElId) : null;
  if (titleEl && typeof titleEl.focus === 'function') {
    titleEl.focus();
  } else if (sectionEl) {
    sectionEl.setAttribute('tabindex', '-1');
    sectionEl.focus && sectionEl.focus();
  }
}

// 交給動畫函式統一處理
function showPage(page) {
  const pages = {
    home:         document.getElementById('recordFormSection'),
    history:      document.getElementById('historySection'),
    moodTest:     document.getElementById('moodTestSection'),
    moodDecision: document.getElementById('moodDecisionSection'),
    taskSchedule: document.getElementById('taskScheduleSection')
  };

  const toEl   = pages[page] || pages.home;
  const fromEl = getVisibleSection(); // ← 先在任何 hidden 變動前抓目前可見頁

  // 切換前:把將不顯示的先設 hidden(交由動畫函式處理)
  Object.values(pages).forEach(sec => { if (sec && sec !== toEl) {/* 延後 hidden 到動畫事件中處理 */} });
  // 切換後要做的事情(渲染、聚焦)
  const afterShow = () => {
    // 顯示後再做渲染與聚焦(避免還在動畫中就操作 DOM)
    if (toEl === pages.history) {
      renderMoodChartsAll().finally(() => {
        typeof renderHistory === 'function' && renderHistory();
        typeof renderQuoteWall === 'function' && renderQuoteWall();
      });
      const h1 = document.getElementById('history-title'); h1 ? h1.focus() : focusOrFallback(toEl);
    } else if (toEl === pages.moodTest) {
      focusOrFallback(toEl, 'mood-title');
    } else if (toEl === pages.moodDecision) {
      (document.getElementById('btnStartNow') || toEl).focus?.();
    } else if (toEl === pages.taskSchedule) {
      renderTaskCard();
      (document.getElementById('taskCard') || document.getElementById('morningSlot') || toEl).focus?.();
    } else {
      // home
      if (typeof focusFirstField === 'function') focusFirstField();
      else focusOrFallback(toEl);
    }
  };

  switchSectionWithAnimation(fromEl, toEl, afterShow);
}

2-5. 在 init() 內呼叫微互動增強

// [Day28-CHANGED] 初次載入:加入微互動增強
(function init(){
  showIntro();
  if (sortSelect)   sortSelect.value = currentSort;
  if (filterSelect) filterSelect.value = currentFilter;

  // ★ 新增:掛上 Ripple 等微互動
  enhanceMicroInteractions();
})();

驗證

  1. 頁面過場:在表單、歷史、測驗、安排頁間切換,有柔和淡入/淡出 + 微位移;開啟系統「減少動態」時,轉場直接切換、無動畫。
  2. 拖曳高亮:拖曳任務卡 → 卡片略縮小 + 陰影加深;拖到任一時段格子上 → 格子藍色光暈與微微浮起。
  3. Ripple:點擊主要按鈕(Submit、馬上開始、匯出、備份、分享) → 出現由點擊位置擴散的波紋;連續點擊能重啟。
  4. 可用性:所有互動不影響既有功能;回到歷史頁仍會正常渲染圖表與語錄牆。
  5. 深色模式:Ripple 在深色主題下亮度稍降,不會刺眼。

常見錯誤 & 排查

  1. 為什麼動畫沒跑?

    • 檢查是否開啟了系統「減少動態」(Chrome DevTools 模擬或作業系統設定)。
    • 確認元素在動畫期間沒有被 hidden:我們已改為在 animationend 才切 hidden。若你自行對 section 設 hidden,會中斷動畫。
  2. Ripple 沒出現:

    • 按鈕需有 .ripple-btn(我們在 enhanceMicroInteractions() 會自動加到主動作鈕)。
    • 需 position: relative; overflow: hidden; 才能裁切波紋。
  3. 拖曳沒反應:

    • 確保 #taskCard 有 draggable="true"(Day 17/16 已設)。
    • dragover 必須 e.preventDefault() 才能觸發 drop(你的程式已包含)。
  4. 轉場抖動/閃爍:

    • 避免在進出場同時改變大量 layout;盡量只在透明度與微位移上動作。
    • 若需要,將容器設定固定最小高度,減少 reflow。
  5. 動畫卡住導致無法點擊:
    檢查 .page-is-switching 是否未移除,或觀察是否有未觸發的 animationend(可用備援 timeout)。


上一篇
【Day 27】— 入門 JavaScript 網頁架設:背景主題切換(深色模式 + 色彩主題)
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言