<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          原生拖拽太拉跨了,純 JS 自己手寫一個拖拽效果,縱享絲滑~

          共 10220字,需瀏覽 21分鐘

           ·

          2022-12-22 11:47

          大廠技術(shù)  高級前端  Node進階

          點擊上方 程序員成長指北,關(guān)注公眾號

          回復1,加入高級Node交流群


          前言

          提到元素拖拽,通常都會先想到用 HTML5 的拖拽放置 (Drag 和 Drop) 來實現(xiàn),它提供了一套完整的事件機制,看起來似乎是首選的解決方案,但實際卻不是那么美好,主要是它的樣式太過簡陋,無法實現(xiàn)更高級的用戶體驗:

          這是瀏覽器默認的一種拖拽效果,隨便拖拽任意的圖片都會產(chǎn)生(包括文字)

          筆者因為之前有個小項目需要經(jīng)常參考稿定設(shè)計,一直有留意其元素拖拽的效果(如下圖),而接下來我將以這種效果為藍本,使用原生 JS 實現(xiàn)一個富有動感的 自定義拖拽 效果,話不多說直接開摸。

          實現(xiàn)原理

          首先說下思路,我們需要知道鼠標的三個事件,分別是 mousedownmousemovemouseup ,當點擊按下的時候,克隆一個絕對定位的元素,并標識下"拖拽中"的狀態(tài),接著在 mousemove 中就可以判斷應(yīng)該執(zhí)行的具體方法,從而讓元素隨著鼠標移動起來。

          在監(jiān)聽事件的 event 對象中,有幾個參數(shù)是比較重要的:clientXclientY 標識的鼠標當前橫坐標和縱坐標,offsetX 和 offsetY 表示相對偏移量,可以在 mousedown 鼠標按下時記錄初始坐標,在 mouseup 鼠標抬起時判斷是否在目標區(qū)域中,如果是則用鼠標獲取到的當前的偏移量 - 初始坐標得到元素實際在目標區(qū)域中的位置。

          為方便閱讀,以下所有代碼均有部分省略,且演示動圖可能會卡頓掉幀,文末會放上完整源碼[1]地址,或點擊文末【閱讀原文】查看,代碼量并不多。

          基礎(chǔ)界面

          先簡單實現(xiàn)一個兩欄布局界面,并應(yīng)用上一些 CSS 效果:

          <div id="app">
            <div class="slide">
              <div id="list">
                <img class="item" src="......." />
                <img  .........
              </div>
            </div>
            <div class="content"></div>
          </div>
          #app {
            width100vw;
            height100vh;
            display: flex;
          }
          .active {
            cursor: grabbing;
          }

          .slide {
            width260px;
            height100%;
            overflow: scroll;
            border-right1px solid rgba(0,0,0,.15);
            #list {
              user-select: none;
              .item {
                background
          : rgba(0,0,0,.15);
                width120px;
                display: inline-block;
                break-inside: avoid;  
                margin-bottom4px;
              }
              .item:hover {
                cursor: grab;
                filter: brightness(90%);
              }
              .item:active {
                cursor: grabbing;
              }
            }
            .grid {
                column-count2;
                column-gap0px;
            }
          }
          .slide::-webkit-scrollbar {
            display: none; /* Chrome Safari */
          }

          #content {
            position: relative;
            flex1;
            height100%;
            margin-left45px;
            background: rgba(0,0,0,.07);
            .item {
              position: absolute;
              transform-origin: top left;
            }
          }

          利用濾鏡屬性 filter: brightness() 可以調(diào)節(jié)明亮度可以快速實現(xiàn)一個鼠標覆蓋的動態(tài)效果,無需額外制作遮罩:

          使用偽類激活 cursor 的 grab 和 grabbing 可以設(shè)置抓取動作的圖標:

          實現(xiàn)元素抓取

          利用事件委托機制[2]為選擇列表添加 mousedown 事件監(jiān)聽,實現(xiàn)抓取的原理是在鼠標按下時克隆按下的元素,并把克隆出來的元素設(shè)置成絕對定位,讓它"浮"起來:

          let dragging = false
          let cloneEl = null // 克隆元素
          let initial = {} // 初始化數(shù)據(jù)記錄
          ......
          // 選中了元素
          cloneEl = e.target.cloneNode(true// 克隆元素
          cloneEl.classList.add('flutter'// 使其浮動
          e.target.parentElement.appendChild(cloneEl) // 加入到列表中
          dragging = true // 標記拖動開始

          // TODO: 初始化克隆元素的定位并記錄,方便后面移動時計算位置
          ........
          .flutter {
            position: absolute;
            z-index9999;
            pointer-events: none;
          }

          將鼠標的坐標設(shè)置為克隆元素的絕對定位值(lefttop),就會像下圖所示這樣,此時減去 offset 偏移量,就能讓克隆元素覆蓋在本體上面。


          初始化的值需要記錄起來方便后續(xù)計算,同時我們用 dragging 變量標記了狀態(tài)(拖動中),接下來配合移動鼠標的監(jiān)聽事件就能將元素“抓”起來了:

          // 鼠標移動
          window.addEventListener("mousemove", (e) => {
            if (dragging && cloneEl) {
              // TODO: 處理元素的移動:改變 left top 定位
              // x 軸(left)計算方法:e.clientX - initial.offsetX
              // y 軸(top)計算方法:e.clientY - initial.offsetY
            }
          })

          上面只是實現(xiàn)了元素的拖動,但是"克隆"的效果實在太明顯了,為了讓元素看起來更像是拖出來的而不是復制出來的,我們還要讓本體隱藏,同時DOM結(jié)構(gòu)不能丟失,這時只需在按下拖動時給本體元素設(shè)置個 opacity: 0,結(jié)束時再改回透明度1就能搞定。

          雖然到這功能就算實現(xiàn)了,但實際效果還是有點僵硬,參考稿定設(shè)計中的元素放開時會固定回到一個位置,然后再收回去,這個過渡又有點鬼畜,不夠流暢。其實只需讓元素回退過程有一個自然地動畫就行,transition 就能實現(xiàn):

          .is_return {
            transition: all 0.3s;
          }
          // 鼠標抬起
          window.addEventListener("mouseup", (e) => {
            dragging = false
            if (cloneEl) {
                cloneEl.classList.add('is_return'// 加上過渡動畫
                changeStyle(......) // 設(shè)置回元素的初始位置
                setTimeout(() => {
                  cloneEl.remove() // 移除元素
                }, 300)
            }
          })

          最終我在動作結(jié)束時給克隆元素添加了過渡屬性,然后直接設(shè)置回初始坐標讓克隆元素回到它的出生地點,用定時器在過渡動畫持續(xù)的相同時間后移除克隆元素,這樣就有了一個平滑穩(wěn)定的回退動畫。

          性能優(yōu)化

          由于在改變元素狀態(tài)的過程中需要頻繁進行多個 CSS 操作,為降低回流重繪的成本,最好將多個操作合并起來處理,這里利用了 cssText 來實現(xiàn):

          // 改變漂浮元素:x、y、縮放倍率
          function moveFlutter(x, y, d = 0) {
            const scale = d ? initial.width + d < initial.fakeSize ? `transform: scale(${(initial.width + d) / initial.width})` : null : null
            const options = [`left: ${x}px``top: ${y}px`]
            scale && options.push(scale)
            // 將CSS處理成數(shù)組,然后丟進DOM操作方法中一次執(zhí)行
            changeStyle(options)
          }
          // 合并多個操作
          function changeStyle(arr) {
            const original = cloneEl.style.cssText.split(';')
            original.pop()
            cloneEl.style.cssText = original.concat(arr).join(';') + ';'
          }

          實現(xiàn)拖拽放大

          放大我們可以使用 transform: scale 來實現(xiàn),只需要將拖動位置之間的距離當做變化系數(shù)(假設(shè)為d),那么scale變化數(shù)值即為(元素寬度 + d)/元素寬度,而放大的最終倍數(shù)必定為 圖片實際寬度/元素的寬度,只要判斷不超過這個邊界就可以。(這個圖片實際寬高在真實業(yè)務(wù)場景中建議在上傳資源時就記錄在數(shù)據(jù)庫,這里我是模擬的隨機一個原圖尺寸)。

          兩點間距離計算公式為

          代碼實現(xiàn):
          // 計算兩點之間距離
          function distance({ clientX, clientY }) {
            const { clientX: x, clientY: y } = initial // 獲取初始的坐標
            const b = clientX - x;
            const a = clientY - y;
            return Math.sqrt(Math.pow(b, 2) + Math.pow(a, 2))
          }

          window.addEventListener("mousemove", (e) => {
            if (dragging && cloneEl) {
              const d = distance(e) // 計算距離
              moveFlutter(e.clientX - initial.offsetX, e.clientY - initial.offsetY, d)
            }
          })
          function moveFlutter(x, y, d = 0) {
            let scale = ''
            // 如果距離大于0,且寬度+距離小于實際寬度
            if( d && initial.width + d <= initial.fakeSize ) {
                scale = `transform: scale(${(initial.width + d) / initial.width})`
            }
            // TODO ... changeStyle ...
          }

          效果演示,GIF稍微掉幀:

          注意元素都要設(shè)置 transform-origin: top left; 改變縮放原點到左上角,否則默認(中心為原點)的轉(zhuǎn)換會發(fā)生比較明顯的偏移。

          實現(xiàn)放置

          其實拖拽放置有點像是"復制"與"粘貼",前面我們實現(xiàn)了復制,放置主要就是將元素粘貼到畫布當中,流程步驟如下:

          1. 1. 如果鼠標在目標區(qū)域,拷貝元素到畫布中,如果不在畫布中,執(zhí)行倒退動畫

          2. 2. 刪除元素

          // 完成處理
          function done(x, y) {
            if (!cloneEl) { return }
            const newEl = cloneEl.cloneNode(true)
            newEl.classList.remove('flutter')
            newEl.src = cloneEl.getAttribute('raw'// 設(shè)置原圖地址
            newEl.style.cssText = `left: ${x - initial.offsetX}px; top: ${y - initial.offsetY}px;`
            document.getElementById('content').appendChild(newEl)
            // TODO: 元素移除
          }

          判斷是否在畫布內(nèi)抬起很簡單,往畫布上綁定mouseup監(jiān)聽事件即可,克隆的新元素必須刪除無用的屬性和class,此時設(shè)置元素的lefttop即可將元素放置進畫布中,關(guān)鍵點在于畫布內(nèi)的target有可能是錯的,因為如果鼠標抬起的區(qū)域已經(jīng)放置了元素,那么相對偏移量就得我們自己計算了,使用getBoundingClientRect方法獲取畫布本身相對于視窗的偏移,鼠標坐標減去畫布本身的偏移就是元素在畫布中的位置了。


          document.getElementById('content').addEventListener("mouseup", (e) => {
            if (e.target.id !== 'content') {
              const lostX = e.x - document.getElementById('content').getBoundingClientRect().left
              const lostY = e.y - document.getElementById('content').getBoundingClientRect().top
              done(lostX, lostY)
            } else { done(e.offsetX, e.offsetY) }
          })

          只貼了部分關(guān)鍵代碼,完整代碼參考文末鏈接,或點擊文末【閱讀原文】。

          邊界判斷

          如果不對邊界情況進行處理可能會導致拖動時發(fā)生意外的中斷,無法正確回收克隆元素。

          // 鼠標離開了視窗
          document.addEventListener("mouseleave", (e) => {
            end()
          })
          // 用戶可能離開了瀏覽器
          window.onblur = () => {
            end()
          }

          體驗優(yōu)化

          參考稿定設(shè)計中元素拖拽是直接賦值原圖的,原圖大小通常無法控制,免不了需要加載時間,造成卡頓空白的問題,在網(wǎng)絡(luò)不夠快時體驗尤其尷尬:

          我的優(yōu)化思路是利用瀏覽器加載過同一張圖片就會優(yōu)先讀緩存的機制,先用一個Image加載原圖,等其加載完畢再把拖拽元素的src改成原圖,這樣瀏覽器會"自動"幫我們優(yōu)化加載過程,只需要注意一點,由于是異步任務(wù),一定要做好對應(yīng)標記,不然手速快的時候控制不好觸發(fā)順序。

          function simulate(url, flag) {
            cloneEl.setAttribute('raw', url)
            const image = new Image()
            image.src = url
            image.onload = function () {
              // 異步任務(wù),克隆節(jié)點可能已不存在,flag標記是否拖動的還是當前目標
              cloneEl && initial.flag === flag && (cloneEl.src = url)
            }
          }

          效果演示,故意加大了圖片的分辨率差異:

          圖片從小圖到清晰的加載過程

          整體效果演示

          引用鏈接

          [1] 完整源碼https://code.juejin.cn/pen/7138254014956699656
          [2] 事件委托機制https://juejin.cn/post/7137549438423138341#heading-7


          Node 社群



          我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復「Node」即可。


             “分享、點贊在看” 支持一波??

          瀏覽 38
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  亚洲手机在线 | 人妻水蜜桃 | 日本免费AAA | 有码一区二区三区 | 亚洲无码视频在线看 |