讓拖拽更加人性化?如何自定義dragover樣式

XboxYan 發表於 2022-06-13
歡迎關注微信公眾號: 前端偵探

在 web 開發中,經常會碰到需要拖拽的場景。為了更好的體驗,拖拽區域需要有一定的變化提示,告訴使用者:"現在可以放在這裡了~",例如這樣的

dragover效果

之前在這篇文章講述瞭如何自定義 drag 樣式,這次接著探索一下如何自定義 dragover 樣式。

一、dragenter 和 dragleave

要實現這樣的效果,少不了和dragenterdragleave打交道。

當拖動的元素進入有效的放置目標時, 將會觸發dragenter 事件

當拖動的元素離開有效的放置目標時,將會觸發dragleave 事件

拖拽目標和放置目標

假設現在有這樣一個結構,這裡 img是拖拽目標,div.content是放置目標。

<img>
<div class="content"></div>

然後在document監聽一下

document.addEventListener('dragleave', function(ev) {
    console.log('dragleave', ev.target)
})
document.addEventListener('dragenter', function(ev) {
    console.log('dragenter', ev.target)
})

那麼,將img拖入div.content的過程中,肯定會觸發dragenterdragleave這兩個事件,如下

dragenter和dragleave

如果頁面比較簡單,要自定義拖拽過程就比較容易了

document.addEventListener('dragleave', function(ev) {
    ev.target.toggleAttribute('over',false);
})
document.addEventListener('dragenter', function(ev) {
    ev.target.toggleAttribute('over',true);
})

通過新增over屬性自定義樣式

.content[over]{
  outline: 4px solid slateblue;
}

效果如下

dragover效果

是不是非常容易呢?

實際使用起來其實還存在很多侷限性,下面一一介紹

二、當放置目標有子元素時

大部分情況下,放置目標並不是空的,還有其他子元素,如果採用上面的方式就會有問題了,假設佈局是這樣的,為了區分,可以給需要放置的元素新增一個屬性,比如allowdrop,表示允許放置

<img>
<div class="content" allowdrop>
    <div>不允許放置</div>
</div>

這裡通過屬性區分一下

document.addEventListener('dragleave', function(ev) {
  if (ev.target.getAttribute('allowdrop')!==null) {
    ev.target.toggleAttribute('over',false);
  }
})
document.addEventListener('dragenter', function(ev) {
  if (ev.target.getAttribute('allowdrop')!==null) {
    ev.target.toggleAttribute('over',true);
  }
})

效果如下

有子元素的情況下

可以看到,當拖拽目標經過子元素時,外面的樣式已經丟失了。原因其實很簡單,在經過子元素時,放置目標也觸發了dragleave事件!

那有沒有辦法不觸發呢?這裡有兩種方式:

首先可以取消dragleave的監聽,因為在執行dragleave時,元素本身是不知道即將進入哪一個區域,很容易“誤傷”。取而代之的是每次dragenter時,先移除上一次放置目標的屬性,然後再新增新的,有點類似選項卡的操作,具體實現如下:

var lastDrop = null;
document.addEventListener('dragenter', function(ev) {
  if (lastDrop) {
    lastDrop.toggleAttribute('over',false);
  }
  const dropbox = ev.target.closest('[allowdrop]'); // 獲取最近的放置目標
  if (dropbox) {
    dropbox.toggleAttribute('over',true);
    lastDrop = dropbox;
  }
})

還有另一種方式:藉助 CSS 就非常容易了

這裡有一個非常簡單粗暴的方式,直接將子元素禁用滑鼠響應,如下

.content[allowdrop][over] *{
  pointer-events: none;
}

這樣,在滑過任何子元素都不會有響應了,完美😁

有子元素的情況,完美

三、多層巢狀放置目標

上面這種方式其實可以解決大多數問題了,畢竟大部分場景都是扁平的。不過有時候也會碰到多層結構,比如那種視覺化編輯工具,尤其是目前比較火的低程式碼平臺,就會涉及到多層結構,假設 HTML 是這樣的

<img>
<div class="content" allowdrop>
  <div class="content" allowdrop></div>
    <div class="content">不允許拖拽</div>
  <div class="content" allowdrop></div>
</div>

如果按照 CSS 的處理方式(JS 方式沒有問題),由於所有子元素都被禁用,裡面的結構自然也無法響應了

多層巢狀結構

那如何讓裡面的放置目標可以響應呢?其實只需要改一下上面的 CSS 即可,如下

.content[allowdrop][over]>*:not([allowdrop]){
  pointer-events: none;
}

這裡使用了>選擇器,表示只選擇子元素,不包含後代元素,然後排除掉放置目標,這樣就能實現多層巢狀了,效果如下

多層巢狀結構,完美

是不是出乎意料的簡單呢?

四、其他互動細節

不知道大家發現沒,上面的例子在拖拽開始,滑鼠就一直處於這種“可放置”狀態,不管是在放置目標外部還是內部,如下

滑鼠指標狀態

這是因為設定了dragover屬性,所以整個document都變成了可放置目標,都允許觸發drop事件

document.addEventListener('dragover', function(ev){
  ev.preventDefault()
})

如果希望互動更加細膩,體驗更好,那麼在滑鼠指示上也可以進一步的優化,可以在進入放置目標後才變成這種狀態,實現如下

document.addEventListener('dragover', function(ev){
  const dropbox = ev.target.closest('[allowdrop]');
  if (dropbox) {
    ev.preventDefault()
  }
})

效果如下(注意觀察滑鼠的變化🔽)

拖拽過程中的滑鼠變化

除此之外,還應該在drop結束後移除掉over屬性

document.addEventListener('drop', function(ev){
  const dropbox = ev.target.closest('[allowdrop]');
  if (dropbox) {
    dropbox.toggleAttribute('over',false);
  }
})

這樣就實現了一個完全通用的自定義 dragover效果,區區數十行,劃重點,完整程式碼如下:

document.addEventListener('dragover', function(ev){
  const dropbox = ev.target.closest('[allowdrop]');
  if (dropbox) {
    ev.preventDefault()
  }
})

document.addEventListener('drop', function(ev){
  ev.target.toggleAttribute('over',false);
})

document.addEventListener('dragleave', function(ev) {
  if (ev.target.getAttribute('allowdrop')!==null) {
    ev.target.toggleAttribute('over',false);
  }
})
document.addEventListener('dragenter', function(ev) {
  if (ev.target.getAttribute('allowdrop')!==null) {
    ev.target.toggleAttribute('over',true);
  }
})

// 或者以下方式,無需dragleave,無需額外 CSS
var lastDrop = null;
document.addEventListener('dragenter', function(ev) {
  if (lastDrop) {
    lastDrop.toggleAttribute('over',false);
  }
  const dropbox = ev.target.closest('[allowdrop]'); // 獲取最近的放置目標
  if (dropbox) {
    dropbox.toggleAttribute('over',true);
    lastDrop = dropbox;
  }
})

當然還少不了 CSS 的配合,同樣重要

[allowdrop]:empty::after{
  content: '拖放此處';
}
[allowdrop][over]:empty::after{
  content: '鬆開放置';
}
[allowdrop][over]{
  /*自定義樣式*/
}
[allowdrop][over]>*:not([allowdrop]){
  pointer-events: none;
}
這裡有個 CSS 小技巧,上面例子在拖放過程中的文字提示變化其實是通過偽元素實時變化的~

你也可以檢視線上連結:自定義 dragover (codepen.io)或者自定義 dragover (juejin.cn)

另外,如果需要完全自定義拖拽,可以參考這個專案:https://github.com/XboxYan/draggable-polyfill,非常輕量,100 來行程式碼,不影響業務邏輯,非常適合學習和時使用,歡迎 star~

五、總結和說明

以上就是自定義 dragover 效果的完整實現了,不算複雜,但也有一些小技巧,特別是藉助了 CSS 的能力。其實在這一版實現之前,我還嘗試過很多別的實現,但都不如這種方式簡潔明瞭,下面總結一下:

  1. 為了更好的體驗,可以在拖拽過程中給與使用者適當的變化提示
  2. 主要實現方法在於 dragenter 和 dragleave
  3. 當放置目標存在子元素時,也會觸發 dragleave 事件,干擾原有邏輯
  4. 可以移除 dragleave 去除子元素的干擾,dragenter 需要先移除再新增 over
  5. 通過 CSS pointer-events 可以去除子元素的干擾
  6. 如果有多層可放置結構,可以通過 :not 過濾可放置目標
  7. 通過滑鼠指標也可以改善互動體驗
  8. 在 DOM 操作中千萬不要忘記了 CSS,這點很重要

當然,拖拽在頁面中的互動細節還有很多,比如拖拽排序過程中的擠壓動畫效果,後面有空再研究吧,爭取出一個通用的解決方案。最後,如果覺得還不錯,對你有幫助的話,歡迎點贊、收藏、轉發❤❤❤

歡迎關注微信公眾號: 前端偵探