關于前端里的拖拖拽拽,了解一下?
最近在項目中使用了 react-dnd[1],一個基于 HTML5 的拖拽庫,“拖拽能力”豐富了前端的交互方式,基于拖拽能力,會擴展各種各樣的拖拽反饋效果,因此有必要學習了解,最好的學習方式就是實操!
拖拽交互常見于各種前端編輯器里,而“編輯器”是一個集成前端技術能力的綜合性工程,其中就會涉及到各種形式的拖拽交互,因為“拖拽”是提升用戶體驗的重要交互方式,所以需要對拖拽的交互效果做各種定制化,作為開發者理應熟練掌握“拖拽”的使用!
最近在開發一款低代碼平臺,所以借此機會分享一下關于“拖拽”這一交互的基礎知識和實踐經驗,希望可以給有需要的同學提供一點參考。
一、HTML5 中的拖放
拖(Drag)和放(Drop)是 HTML5 標準的組成部分,了解掌握之后,舉一反三,有助于提升我們在拖拽場景下技術方案的設計能力。
1.1 draggable 屬性
現代瀏覽器中,不難發現,圖片標簽()是可以被長按拖拽,但如果需要自定義的 DOM 節點可以被拖拽需要配置以告訴瀏覽器提供對元素(Element / Tag)支持拖拽的能力。
而元素是否允許被拖放且可響應 API 操作依賴于 draggable[2] 全局標簽屬性
draggable 是一個布爾值類型的標簽屬性:
- true:元素可被拖拽
- false:元素不可拖拽
當元素設置了 draggable 屬性,此時長按就可以自由拖拽了:
1.2 Darg & Drop 事件
HTML 的 drag & drop 使用了“DOM Event”和從“Mouse Event”繼承而來的“drag event” 。
一個典型的拖拽操作: 用戶選中一個可拖拽的(draggable)元素,并將其拖拽(鼠標按住不放)至一個可放置的(droppable)元素上,然后松開鼠標。
在拖動元素期間,一些與拖放相關的事件會被觸發,像 drag 和 dragover 類型的事件會被頻繁觸發。
除了定義拖拽事件類型,每個事件類型還賦予了對應的事件處理器
各個事件的時機可以用下面這個圖簡單表示:
??注意: dragOver 事件的默認行為是:“Reset the current drag operation to "none"”。也就是說,如果不阻止放置元素的 dragOver 事件,則放置元素不會響應“拖動元素”的“放置行為”
// 讓綁定該事件的元素支持放置
function handleDragOver(e) {
// 阻止默認的重置行為
// 即可成為拖拽元素的放置區
e.preventDefault();
}
從設計事件標準來看,如果我們需要自行實現拖拽的效果,就需要從這關鍵的幾個事件去思考設計。
1.3 DataTransfer
在上述的事件類型中,不難發現,放置元素和拖動元素分別綁定了自己的事件,可如何將拖拽元素和放置元素建立聯系以及傳遞數據?
這就涉及到 DataTransfer 對象:
DataTransfer 對象用于保存拖動并放下(drag and drop)過程中的數據。它可以保存一項或多項數據,這些數據項可以是一種或者多種數據類型。—— DataTransfer - MDN[3]
DataTransfer 對象在不同瀏覽器上因為標準可能不一樣使得 API 有差異,但有幾個“標準(常用)”屬性和方法需要熟悉
在 Chrome 瀏覽器上的 DataTransfer 實例如下:
(1) 屬性
(2) 方法
在簡單的拖拽場景中,其實可以類比 window.localStorage 對象的 setItem() 和 getItem() 方法來理解記憶.
但 getData() 在測試中發現只能在 ondrop 事件中獲取到值:
1.4 一個案例掌握拖放 API
拖動元素 放置區域
<div>
<div class="drag" draggable="true" id="dragger" ondragstart="handleDragStart(event)">拖動元素</div>
<div class="drop" ondrop="handleDrop(event)" ondragover="allowDrop(event)">放置區域</div>
</div>
<script>
function handleDragStart(e) {
e.dataTransfer.setData('DRAG_NODE_ID', e.target.id)
}
function handleDragOver(e) {
e.preventDefault();
}
function handleDrop(e) {
e.preventDefault();
var data = e.dataTransfer.getData('DRAG_NODE_ID');
e.target.appendChild(document.getElementById(data));
}
</script>
演示案例: https://codepen.io/DYBOY/pen/eYeyvWm
效果:
演示
拖拽演示效果
1.6 兼容性
是 HTML5 標準提出的能力,因此各大瀏覽器廠商對于標準的支持有差異,其兼容性參考如下:
相較于傳統的通過鼠標事件:mousedown、mousemove、mouseup 組合實現的拖拽要簡單很多,少了放入目標邊界的判斷,也少了對位置的實時獲取操作。
另外目前的 API 不算多,例如我們想要定制化拖拽的圖片大小、鼠標樣式等,目前暫時沒發現比較方便的解決方式,但是從另一個角度來說,讓我們對于拖拽能力的設計和標準有了一個更深切的認識,對于設計實現拖拽交互有了一個“理論”基礎!
二、手搓一個
有了上面的基礎知識,那么實現一個列表拖拽排序并不是什么難事。
2.1 設計實現
結合上述的 Drag & Drop 的事件類型,那么拖拽排序主要是針對“拖動對象”之間相互作用關系的邏輯梳理,此處我們暫且區分為:
- 源對象: 拖拽列表中被拖動的單個列表項
- 目標對象: 拖拽列表中和“源對象”產生“相互作用”的列表項
整體的交互事件的設計思路如下:
(1) ondragstart
此時開始拖拽“源對象”的時機,在此事件回調函數中改變“源對象”的樣式,設置拖拽的一些傳遞參數等初始值。
// 源對象開始拖拽
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.effectAllowed = "move";
setDragId(e.currentTarget.dataset.index); // 從 dataset 獲取拖拽項的 id
};
(2) ondragover
正與拖拽中的“源對象”產生相互影響的目標對象,此時“源對象”處于“目標對象”的正上方,目標對象 100ms/次的頻率調用“目標對象”的 ondragover 中聲明的回調事件。
此時,我們會計算改變“源對象”和“目標對象”的位置。
// 源對象在目標對象上方時
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); // 允許放置,阻止默認事件
const dropId = e.currentTarget.dataset.index;
move(dragId, dropId); // 改變原列表數據
};
(3) ondrag
該事件作用于“源對象”,此時正處于拖拽過程中,此時可以改變源對象的 opacity、display(none)、visiblity 樣式屬性,如果在 dragstart 事件改變,則會導致拖拽拷貝對象丟失。
// 源對象被拖拽過程中
const handleDrag = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget.style.opacity = "0";
};
(4) ondragend
在松手完成“源對象”的放置時,主動調用綁定在“源對象”身上的事件,此時恢復更改的樣式。
// 源對象被放置完成時
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget.style.opacity = "1";
};
2.2 實現效果
2.3 加點動畫
上面的實現中效果還算可以,但是少了拖拽項的切換過程動畫,直接在 dragover 事件中通過 move(dragId, dropId) 方法直接修改了原列表數據的排序,導致切換突變。
借助 animation 新增 CSS 幀動畫:
@keyframes dropUp {
100% {
transform: translateY(5px);
}
}
@keyframes dropDown {
100% {
transform: translateY(-5px);
}
}
.drop-up{
animation: dropUp 0.3s ease-in-out forwards;
}
.drop-down{
animation: dropDown 0.3s ease-in-out forwards;
}
同樣的在 dragOver 事件中處理,新增邏輯代碼:
// 源對象在目標對象上方時
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
...
// 設置動畫
const dropId = e.currentTarget.dataset.index;
const dragIndex = findIndex(listData, (i) => i.id === dragId);
const dropIndex = findIndex(listData, (i) => i.id === dropId);
// 通過增加對應的 CSS class,實現視覺上的動畫過渡
e.currentTarget.classList.remove("drop-up", "drop-down");
if (dragIndex < dropIndex) {
e.currentTarget.classList.add("drop-down");
} else if (dragIndex > dropIndex) {
e.currentTarget.classList.add("drop-up");
}
...
};
增加了動畫的效果:
增加了動畫的效果
看起來似乎好一點了,當然大家可以去擴充動畫的效果,亦或者借助三方動畫庫。
三、已有拖拽庫
目前主流的拖拽庫有:
- react-dnd: https://github.com/react-dnd/react-dnd/
- react-beautiful-dnd: https://github.com/atlassian/react-beautiful-dnd/
- sortablejs: https://sortablejs.github.io/Sortable/
- react-sortable-hoc: https://github.com/clauderic/react-sortable-hoc/
關于幾者的差異,可以參閱:《關于react中使用拖拽插件的評測[4]》
四、總結
由于低代碼平臺其實會有豐富的拖拽場景,從可擴展和兼容性上考慮,最終選擇了 react-dnd 作為基礎拖拽庫,當然,在復雜的拖拽場景下,是需要自行擴展該拖拽庫,上手難度相對會高一點,不過有了這些“拖拽知識”作為前置基礎,那么擴展功能也就不是什么難事了。
參考資料
[1]react-dnd - Github: https://react-dnd.github.io/react-dnd/about
[2]draggable - MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable
[3]DataTransfer - MDN: https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer
[4]關于react中使用拖拽插件的評測: https://juejin.cn/post/6956112150989373448