新的酷東東。
簡述
拖曳可以應用的場景有很多,像是:
- 用拖拉的方式來上傳檔案
- 用拖拉的方式來交換元素位置
- 把東西從 A 拖拉到 B 點(例如驗證 or 圖形介面)
所以覺得這還蠻值得學的,可以運用的地方有很多。
首先來一個 MDN 的範例,我們要做的是像這樣的效果:
如果你想直接看原始碼的話可以到 Codepen 上看,接下來會逐步介紹相關的 API 和思路。
這邊先補充一個概念,如果要讓一個元素是「draggable(可拖曳)」 的話,要加上 draggable="true"
這個屬性:
1
| <div class="ball" draggable="true"></div>
|
如果沒有加的話預設都是無法拖曳的,所以一定要記得哦。
接著來解釋這邊用到的 API,總共有三個:
dragstart
當元素開始被拖曳的時候會觸發。
這邊是利用了這個 function 來取得「目前拖曳的元素是誰」:
1 2 3 4
| let dragItem = null document.addEventListener('dragstart', (e) => { dragItem = e.target })
|
之所以要存起來是因為我們希望在「某個時機點」把它插到其他 DOM 元素上,所以才需要這個 reference。
dragover
當有「draggable(元素 / 檔案)」的東西被拖曳進來時觸發,而且是連續觸發。
這邊通常可以做 dragging 的醒目效果,不過更重要的是 e.preventDefault
,因為它可以:
- 讓元素從「不能被 drop」變成「可以被 drop」的狀態。
- 讓元素從「不能被 drop」變成「可以被 drop」的狀態。
- 讓元素從「不能被 drop」變成「可以被 drop」的狀態。
以目前的範例來說不是很好懂,所以這邊會用另一個範例解釋。
要看一個元素是否能 drop 可以從 cursor 有沒有顯示 + 號 來判斷,像這樣:
灰色區塊因為有加上 e.preventDefault
,所以當它被紅球 dragover
時就會顯示 + 號,代表這裡是可以 drop 的。
詳細可以自己到 Codepen 上玩玩看。
drop
當「draggable(元素 / 檔案)」的東西放掉的時候觸發。
回到原本的例子,我們希望在「紅球放掉的時候」來更新他的位置,所以寫了這樣的判斷:
1 2 3 4 5 6 7 8 9 10 11 12 13
| document.addEventListener('drop', (e) => { e.preventDefault() const targetElement = e.target if (targetElement.classList.contains('dropzone')) { dragItem.parentNode.removeChild(dragItem) e.target.append(dragItem) } })
|
簡單來說只要紅球 drop 的元素有 dropzone
這個 calss 就會「更新位置」,所以大致的思路就是這樣。
不過這邊想特別提一件事情,就是要注意「 addEventListener
是綁在誰身上?」
會提這個是因為當「綁定的對象不同時思路也會不同」。以剛剛的範例來看都都是綁在 document
身上,所以像是 drop
不管是在哪裡發生的都會被觸發。
可是如果現在 addEventListener
是掛在 .target
身上的話就不一樣了,這樣就只有在「東西被 drop 到它身上時」才會觸發這個事件(有點像是第一人稱跟第三人稱的差別),這一段如果不懂的話建議自己多寫幾個範例來玩玩看,應該比較能體會我想表達的意思。
製作可拖曳的列表
這個算是蠻常見的需求,所以能練習一下。至於思路都寫在註解中了,不懂的話試著自己做做看就懂了。
可能會比較有疑問的地方應該是 e.dataTransfer
的部分,這裡稍微解釋一下。簡單來說就是讓你「幫被拖曳的元素儲存資訊」,這樣子在做判斷時才有相關的 reference,詳細的用法可以參考 MDN,或這篇 文章
JavaScript + jQuery
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <body> <ul class="list"> <li>One</li> <li>Two</li> <li>Three</li> <li>Four</li> </ul>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script> const items = document.querySelectorAll('li') items.forEach((li) => { $(li).prop('draggable', true) li.addEventListener('dragstart', dragStart) li.addEventListener('drop', dropped) li.addEventListener('dragover', (e) => e.preventDefault()) })
function dragStart(e) { const index = $(e.target).index() e.dataTransfer.setData('text/plain', index) }
function dropped(e) { e.preventDefault() const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10) const target = $(e.target) const newIndex = $(e.target).index()
const dropped = $(this).parent().children().eq(oldIndex).remove()
oldIndex < newIndex ? target.after(dropped) : target.before(dropped) } </script> </body>
|
原始碼可到 CodePen 參考。
其實最關鍵的地方就是 dragStart
時儲存「原始位置」,接著 dropped
時取得「新的位置」,最後再根據「新 / 舊位置」來判斷要放到那個位置,簡單來說就是這樣吧。
React
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| export default function App() { const [items, setItems] = useState(['One', 'Two', 'Three', 'Four'])
return ( <ul className='list'> {items.map((item, index) => ( <li draggable='true' key={item} onDragOver={(e) => e.preventDefault()} onDragStart={(e) => { // 儲存 drag 時的位置 e.dataTransfer.setData('text/plain', index) }} onDrop={(e) => { const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10) // drop 的位置 let targetIndex = index const newItems = [...items] // remove the old one const removedItem = newItems.splice(oldIndex, 1) // insert to target index newItems.splice(targetIndex, 0, ...removedItem) setItems(newItems) }} > {item} </li> ))} </ul> ) }
|
原始碼可到 CodePen 來看。
React 相對來說會直覺一些,畢竟只要專注在「資料」就好,不過要留意一下 splice
的部分。第一個參數可以想成是「我希望元素放到的位置」,這樣子會比較好理解一點。
例如說我想把某個元素放到第一個位置,那就會用 splice(0, 0, item)
,第二個的話就是 splice(1, 0, item)
,以此類推。就不用像寫 jQuery 時還要多判斷「插到前面還是後面」的這一步。
可拖曳檔案(React)
做完上面的練習後應該就能做出這樣的功能:
原始碼可到 CodePen 參考
主要只是多了「上傳檔案」的功能,背後的拖曳邏輯其實都跟剛剛差不多,所以這邊就直接附上 code 不解釋太多了,可以參考註解邊練習看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| export default function App() { const [uploadFiles, setUploadFiles] = useState([])
return ( <> <div className='upload'> <p className='upload-title'>Link path</p> <div className='upload-area' // allow drop onDragOver={(e) => e.preventDefault()} // receive file (拖曳進來的檔案) onDrop={(e) => { // prevent dowload e.preventDefault() if (e.dataTransfer.files.length > 0) { setUploadFiles(Array.from(e.dataTransfer.files)) } }} > <div className='upload-area-list'> <ul className='list'> {uploadFiles.map((file, index) => ( <li draggable='true' key={file.name} onDragOver={(e) => { // allow drop e.preventDefault() // stop bubbling to parent (.upload-area) e.stopPropagation() }} onDragStart={(e) => e.dataTransfer.setData('text/plain', index)} onDrop={(e) => { // prevent dowload e.preventDefault() // stop bubbling to parent (.upload-area) e.stopPropagation() const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10)
// 避免從桌面拖曳檔案到 li 上的情況 if (!isNaN(oldIndex)) { let targetIndex = index // 因為是 blob 所以得用這種方式 copy const newFiles = uploadFiles.map((file) => new File([file], file.name)) const removedBackup = newFiles[oldIndex] // remove the old one newFiles.splice(oldIndex, 1) // insert to target index newFiles.splice(targetIndex, 0, removedBackup) setUploadFiles(newFiles) } }} > <span className='file-name'> 0{index + 1}. {file.name} </span> <span onClick={() => setUploadFiles((prev) => prev.filter((f) => f.name !== file.name)) } className='material-icons material-icons-close' > close </span> </li> ))} </ul> </div> <button className='upload-area-button'> <span className='material-icons'>file_upload</span> <input type='file' multiple onChange={(e) => setUploadFiles(Array.from(e.target.files))} /> </button> </div> </div> <button className='btn-save' onClick={() => console.log(uploadFiles)}> Save </button> </> ) }
|
這邊要特別注意 e.stopPropagation
的功能,如果沒有在該加的地方加上的話就有可能遇到「事件從 children 冒泡到 parent」的問題。
像是假設我在 <li>
觸發了 drop
事件,沒有取消傳遞的話就會冒泡到 .upload-area
的位置上,所以就會變成這樣:
- 執行
li
的 event callback,把位置交換
- 執行
.upload-area
的 event callback,更新 state
這就跟我們預期的不一樣了,我們只希望li
的 callback 被執行而已。
所以這邊要特別留意事件傳遞機制的問題,不熟的話可以參考我寫的 阻止事件傳遞 stopPropagation。
雖然你也可以暴力解,通通加上 stopPropagation
和 preventDefault
,但我覺得去理解他們實際到底在「預防什麼」會更好。