前言
近期翻新了部落格的首頁,並調整了一些體驗上的問題:
- 更恰當的畫面留白
- 添加手機板互動體驗與樣式
- 更直白的標題

其中一個充滿趣味的元素拖曳功能暗藏了許多細節值得分享。最初是看到 Glitch 網站 圖示像冰箱磁鐵可以隨意挪動的功能覺得充滿趣味,背後是使用 react-draggable 套件製作。
不過考慮到速度與維護性,部落格不打算因為一頁特效就引入額外套件,所以從頭用 DOM API 手刻了相關功能。
實做拖動畫面元素
結果可以到首頁查看或以下 CodePen 重現:
See the Pen Drag Text by Riceball (@riecball) on CodePen.
結構
<span class="relative inline-flex"> <!-- !Hack: Since change element to absolute will cause layout rebuild and weird first grab position, so I create 2 same element here, one is invisible for layout, another one is absolute positioned for user to grab --> <!-- Placeholder --> <span class="invisible hover:-rotate-2 inline-flex items-center gap-1 md:gap-2 border md:border-2 p-1 pr-2 border-current/60 whitespace-nowrap cursor-grab hover:bg-primary/20 hover:text-primary transition"> <!-- icon... --> Design <span class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 -translate-x-1/2 left-0 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 right-0 translate-x-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> </span> <!-- Real --> <span id="draggable" class="absolute hover:-rotate-2 inline-flex items-center gap-1 md:gap-2 border md:border-2 p-1 pr-2 border-current/60 whitespace-nowrap cursor-grab hover:bg-primary/20 hover:text-primary transition"> <!-- icon... --> Design <span class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 -translate-x-1/2 left-0 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 right-0 translate-x-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> </span> </span>
多複製了一個一樣的編輯框,加上 invisible
隱藏來防止顯示與螢幕閱讀器解讀以及佔位,是因為發現使用 JS 動態改變元素的布局屬性勢必會讓畫面布局整個重新計算,如果要有「自然拿起來」的效果,該元素勢必要原先就浮動在畫面上。雖然是有點 Hacky 的作法,但能有效的改善拿取時畫面抖動的體驗。
邏輯
const draggable = document.getElementById("draggable");
let posX = 0;let posY = 0;
const grabbingStyle = [ "bg-primary/20", "text-primary", "-rotate-2", "cursor-grabbing"];
function getClientX(e) { return e.touches ? e.touches[0].clientX : e.clientX;}
function getClientY(e) { return e.touches ? e.touches[0].clientY : e.clientY;}
function moveElement(e) { e.preventDefault(); draggable.style.left = `${getClientX(e) - posX}px`; draggable.style.top = `${getClientY(e) - posY}px`;}
function dragStart(e) { e.preventDefault(); draggable.classList.add(...grabbingStyle); posX = getClientX(e) - draggable.offsetLeft; posY = getClientY(e) - draggable.offsetTop;
window.addEventListener("mousemove", moveElement, false); window.addEventListener("touchmove", moveElement, { passive: false });}function dragEnd() { draggable.classList.remove(...grabbingStyle); window.removeEventListener("mousemove", moveElement, false); window.removeEventListener("touchmove", moveElement, false);}
draggable.addEventListener("mousedown", dragStart, false);draggable.addEventListener("touchstart", dragStart, { passive: false });
window.addEventListener("mouseup", dragEnd, false);window.addEventListener("touchend", dragEnd, false);
由於行動裝置並不存在 hover 偽元素,自然沒辦法用 CSS 的方式去製作其樣式,但還是可以藉由 DOM 的 touch 事件展開監聽並添加樣式。
先拿到目標選取框再設置 mousedown
與 touchstart
事件監聽,並且確認滑動時也要設置 mousemove
與 touchmove
事件監聽,中途留意抓取的動畫效果添加與移除。
從事件監聽傳下來的事件 (e
) 會經過特製的函式判斷「是否為觸控事件返回對應的座標數值」。
function getClientX(e) { return e.touches ? e.touches[0].clientX : e.clientX;}
function getClientY(e) { return e.touches ? e.touches[0].clientY : e.clientY;}
計算「點擊位置距離元素左上角的差距」,將點選元素的左上角座標作為基準。
posX = getClientX(e) - draggable.offsetLeft;
在移動時透過 preventDefault
來阻止預設事件觸發行為,例如:阻止選取文字、阻止觸控裝置上的頁面滾動。
設置元素左與上方定位「根據目前點擊位置讓元素順著當初點下的位置被拖動」。
function moveElement(e) { e.preventDefault(); draggable.style.left = `${getClientX(e) - posX}px`; draggable.style.top = `${getClientY(e) - posY}px`;}
總結
透過 JS 動態監聽與改變元素的樣式達成可互動的 DOM 元素特效,並且用程序式程式的方式製作。我想很多人學習前端就是希望可以自製玩玩看前端特效,實際上從頭用原生 API 撰寫還蠻繁雜且沒效率的😅,可以感受到用木棍蓋高樓的感覺。
- 可以想像未來如果有多個元素需要監聽使用,可以考慮 OOP 或 FP 的方式確保擴充性。
- 效能上使用
transform
丟給 GPU 運算畫面或許能獲得更好的效能而非直接改動left
、top
。 - 事件頻繁觸發 throttle 降低更新次數或
requestAnimationFrame()
或許能幫上忙。