乐趣区

关于前端:原生拖拽太拉跨了纯JS自己手写一个拖拽效果纵享丝滑

前言

提到元素拖拽,通常都会先想到用 HTML5 的拖拽搁置 (Drag 和 Drop) 来实现,它提供了一套残缺的事件机制,看起来仿佛是首选的解决方案,但理论却不是那么美妙,次要是它的款式太过简陋,无奈实现更高级的用户体验:

这是浏览器默认的拖拽成果,点住拖拽任意图片或文字都会产生。

笔者因为之前有个小我的项目须要常常参考 稿定设计 ,始终有注意其元素拖拽的成果(如下图),所以接下来我将以这种成果为底本,应用原生 JS 实现一个富裕动感的 自定义拖拽 成果,话不多说间接开摸。

实现原理

首先说下思路,咱们须要晓得鼠标的三个事件,别离是 mousedownmousemovemouseup,当点击按下的时候,克隆一个相对定位的元素,并标识下 ” 拖拽中 ” 的状态,接着在 mousemove 中就能够判断应该执行的具体方法,从而让元素随着鼠标挪动起来。

在监听事件的 event 对象中,有几个参数是比拟重要的:clientXclientY 标识的鼠标以后横坐标和纵坐标,offsetXoffsetY 示意绝对偏移量,能够在 mousedown 鼠标按下时记录初始坐标,在 mouseup 鼠标抬起时判断是否在指标区域中,如果是则用鼠标获取到的 以后的偏移量 – 初始坐标 失去元素理论在指标区域中的地位。

为不便浏览,以下所有代码均有局部省略,演示 GIF 可能会掉帧,文末可查看 残缺源码 配合本文食用,代码量并不多。

根底界面

先简略实现一个两栏布局界面,并利用上一些 CSS 成果:

<div id="app">
  <div class="slide">
    <div id="list">
      <img class="item" src="......." />
      <img  .........
    </div>
  </div>
  <div class="content"></div>
</div>
#app {
  width: 100vw;
  height: 100vh;
  display: flex;
}
.active {cursor: grabbing;}

.slide {
  width: 260px;
  height: 100%;
  overflow: scroll;
  border-right: 1px solid rgba(0,0,0,.15);
  #list {
    user-select: none;
    .item {background: rgba(0,0,0,.15);
      width: 120px;
      display: inline-block;
      break-inside: avoid;  
      margin-bottom: 4px;
    }
    .item:hover {
      cursor: grab;
      filter: brightness(90%);
    }
    .item:active {cursor: grabbing;}
  }
  .grid {
      column-count: 2;
      column-gap: 0px;
  }
}
.slide::-webkit-scrollbar {display: none; /* Chrome Safari */}

#content {
  position: relative;
  flex: 1;
  height: 100%;
  margin-left: 45px;
  background: rgba(0,0,0,.07);
  .item {
    position: absolute;
    transform-origin: top left;
  }
}

利用滤镜 filter: brightness(90%); 调节亮堂度能够疾速实现一个鼠标笼罩的动态效果,无需额定制作遮罩:

应用伪类激活 cursorgrabgrabbing 能够设置抓取动作的图标:

实现元素抓取

利用 事件委托机制 为抉择列表增加 mousedown 事件监听,实现抓取的原理是在鼠标按下时 克隆 按下的元素,并把克隆进去的元素设置成相对定位,让它 ” 浮 ” 起来:

let dragging = false
let cloneEl = null // 克隆元素
let initial = {} // 初始化数据记录
......
// 选中了元素
cloneEl = e.target.cloneNode(true) // 克隆元素
cloneEl.classList.add('flutter') // 使其浮动
e.target.parentElement.appendChild(cloneEl) // 退出到列表中
dragging = true // 标记拖动开始

// TODO: 初始化克隆元素的定位并记录,不便前面挪动时计算地位
........
.flutter {
  position: absolute;
  z-index: 9999;
  pointer-events: none;
}

将鼠标的坐标设置为克隆元素的相对定位值(lefttop),就会像下图所示这样,此时减去 offset 偏移量,就能让克隆元素笼罩在本体下面。

初始化的值须要记录起来不便后续计算,同时咱们用 dragging 变量标记了状态(拖动中),接下来配合挪动鼠标的监听事件就能将元素“抓”起来了:

// 鼠标挪动
window.addEventListener("mousemove", (e) => {if (dragging && cloneEl) {
    // TODO: 解决元素的挪动:扭转 left top 定位
    // x 轴(left)计算方法:e.clientX - initial.offsetX
    // y 轴(top)计算方法:e.clientY - initial.offsetY
  }
})

下面只是实现了元素的拖动,然而 ”克隆“ 的成果切实太显著了,为了让元素看起来更像是拖出来的而不是复制进去的,咱们还要让本体暗藏,同时 DOM 构造不能失落,这时只需在按下拖动时给本体元素设置个 opacity: 0,完结时再改回透明度 1 就能搞定。

尽管到这性能就算实现了,但实际效果还是有点生硬,参考稿定设计中的元素放开时会固定回到一个地位,而后再发出去,这个过渡又有点鬼畜,不够晦涩。其实只需让元素回退过程有一个天然地动画就行,transition 就能实现:

.is_return {transition: all 0.3s;}
// 鼠标抬起
window.addEventListener("mouseup", (e) => {
  dragging = false
  if (cloneEl) {cloneEl.classList.add('is_return') // 加上过渡动画
      changeStyle(......) // 设置回元素的初始地位
      setTimeout(() => {cloneEl.remove() // 移除元素
      }, 300)
  }
})

最终我在动作完结时给克隆元素增加了过渡属性,而后间接设置回初始坐标让克隆元素回到它的出生地点,用定时器在过渡动画继续的雷同工夫后移除克隆元素,这样就有了一个平滑稳固的回退动画。

性能优化

因为在扭转元素状态的过程中须要频繁进行多个 CSS 操作,为升高回流重绘的老本,最好将多个操作合并起来解决,这里利用了 cssText 来实现:

// 扭转沉没元素: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 解决成数组,而后丢进 DOM 操作方法中一次执行
  changeStyle(options)
}
// 合并多个操作
function changeStyle(arr) {const original = cloneEl.style.cssText.split(';')
  original.pop()
  cloneEl.style.cssText = original.concat(arr).join(';') + ';'
}

实现拖拽放大

放大咱们能够应用 transform: scale 来实现,只须要将拖动地位之间的间隔当做 变动系数 (假如为d),那么scale 变动数值即为 (元素宽度 + d)/ 元素宽度,而放大的最终倍数必然为 图片理论宽度 / 元素的宽度,只有判断不超过这个边界就能够。(这个图片理论宽高在实在业务场景中倡议在上传资源时就记录在数据库,这里我是模仿的随机一个原图尺寸)。

两点间间隔计算公式为:

代码实现:

// 计算两点之间间隔
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 略微掉帧:

留神元素都要设置 transform-origin: top left; 扭转缩放原点到左上角,否则默认 (核心为原点) 的转换会产生比拟显著的偏移。

实现搁置

其实拖拽搁置有点像是 ” 复制 ” 与 ” 粘贴 ”,后面咱们实现了 复制 ,搁置次要就是将元素 粘贴 到画布当中,流程步骤如下:

  1. 如果鼠标在指标区域,拷贝元素到画布中,如果不在画布中,执行倒退动画
  2. 删除元素
// 实现解决
function done(x, y) {if (!cloneEl) {return}
  const newEl = cloneEl.cloneNode(true)
  newEl.classList.remove('flutter')
  newEl.src = cloneEl.getAttribute('raw') // 设置原图地址
  newEl.style.cssText = `left: ${x - initial.offsetX}px; top: ${y - initial.offsetY}px;`
  document.getElementById('content').appendChild(newEl)
  // TODO: 元素移除
}

判断是否在画布内抬起很简略,往画布上绑定 mouseup 监听事件即可,克隆的新元素必须删除无用的属性和 class,此时设置元素的lefttop 即可将元素搁置进画布中,关键点在于画布内的 target 有可能是错的,因为如果鼠标抬起的区域曾经搁置了元素,那么绝对偏移量就得咱们本人计算了,应用 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) }
})

只贴了局部要害代码,残缺代码文末查看。

边界判断

如果不对边界状况进行解决可能会导致拖动时发生意外的中断,无奈正确回收克隆元素。

// 鼠标来到了视窗
document.addEventListener("mouseleave", (e) => {end()
})
// 用户可能来到了浏览器
window.onblur = () => {end()
}

体验优化

参考稿定设计中元素拖拽是间接赋值原图的,原图大小通常无法控制,免不了须要加载工夫,造成卡顿空白的问题,在网络不够快时体验尤其难堪:

我的优化思路是利用浏览器加载过同一张图片就会优先读缓存的机制,先用一个 Image 加载原图,等其加载结束再把拖拽元素的 src 改成原图,这样浏览器会 ” 主动 ” 帮咱们优化这个过程,只须要留神一点,因为这是个异步工作,所以肯定要做好对应标记,不然手速快的时候管制不好触发程序。

function simulate(url, flag) {cloneEl.setAttribute('raw', url)
  const image = new Image()
  image.src = url
  image.onload = function () {
    // 异步工作,克隆节点可能已不存在,flag 标记是否拖动的还是以后指标
    cloneEl && initial.flag === flag && (cloneEl.src = url)
  }
}

成果演示,成心加大了图片的分辨率差别:

残缺代码

点我查看原文残缺代码地址

以上就是文章的全部内容,感激看到这里,心愿对你有所帮忙或启发!创作不易,如果感觉文章写得不错,能够点赞珍藏反对一下,也欢送关注,我会继续更新实用的前端常识与技巧,我是茶无味 de 一天(公众号: 品尝前端),期待与你独特成长~

退出移动版