Implement Draggable DOM Element to Make Website More Interactive and Fun

實作可拖動 DOM 元素增添網站互動與趣味性

前言

近期翻新了部落格的首頁,並調整了一些體驗上的問題:

  • 更恰當的畫面留白
  • 添加手機板互動體驗與樣式
  • 更直白的標題

Glitch 網站貼紙

其中一個充滿趣味的元素拖曳功能暗藏了許多細節值得分享。最初是看到 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 事件展開監聽並添加樣式。

先拿到目標選取框再設置 mousedowntouchstart 事件監聽,並且確認滑動時也要設置 mousemovetouchmove 事件監聽,中途留意抓取的動畫效果添加與移除。

從事件監聽傳下來的事件 (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 撰寫還蠻繁雜且沒效率的😅,可以感受到用木棍蓋高樓的感覺。

  1. 可以想像未來如果有多個元素需要監聽使用,可以考慮 OOP 或 FP 的方式確保擴充性。
  2. 效能上使用 transform 丟給 GPU 運算畫面或許能獲得更好的效能而非直接改動 lefttop
  3. 事件頻繁觸發 throttle 降低更新次數或 requestAnimationFrame()🔗 或許能幫上忙。