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()🔗 或许能帮上忙。