当前位置:首页 > 技术分析 > 正文内容

原生JS手写丝滑流畅的元素拖拽效果

ruisui882个月前 (03-02)技术分析12

前言

提到元素拖拽,通常都会先想到用 HTML5 的拖拽放置 (Drag 和 Drop) 来实现,它提供了一套完整的事件机制,看起来似乎是首选的解决方案,但实际却不是那么美好,主要是它的样式太过简陋,无法实现更高级的用户体验:

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

笔者因为之前有个小项目需要经常参考稿定设计,一直有留意其元素拖拽的效果(如下图),所以接下来我将以这种效果为蓝本,使用原生 JS 实现一个富有动感的 自定义拖拽 效果,话不多说直接开摸。

实现原理

首先说下思路,我们需要知道鼠标的三个事件,分别是 mousedownmousemovemouseup ,当点击按下的时候,克隆一个绝对定位的元素,并标识下"拖拽中"的状态,接着在 mousemove 中就可以判断应该执行的具体方法,从而让元素随着鼠标移动起来。

在监听事件的 event 对象中,有几个参数是比较重要的:clientXclientY 标识的鼠标当前横坐标和纵坐标,offsetXoffsetY 表示相对偏移量,可以在 mousedown 鼠标按下时记录初始坐标,在 mouseup 鼠标抬起时判断是否在目标区域中,如果是则用鼠标获取到的当前的偏移量 - 初始坐标得到元素实际在目标区域中的位置。

为了阅读体验,以下所有代码均有部分省略,文末可查看完整源码地址,代码量并不多。

基础界面

先简单实现一个两栏布局界面,并应用上一些 CSS 效果:

#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 ...
}

效果演示:

注意元素都要设置 transform-origin: top left; 改变缩放原点到左上角,否则默认(中心为原点)的转换会发生比较明显的偏移。

实现放置

其实拖拽放置有点像是"复制"与"粘贴",前面我们实现了复制,放置主要就是将元素粘贴到画布当中,流程步骤如下:

  1. 如果鼠标在目标区域,拷贝元素到画布中,如果不在画布中,执行倒退动画
  2. 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)
  }
}

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


以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注,我会更新更多实用的前端知识与技巧,我是茶无味的一天,期待与你共同成长~

相关链接

[1] 完整代码地址: https://juejin.cn/post/7145447742515445791/#heading-9
[2] 关于作者:
https://book.palxp.com

扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/2422.html

分享给朋友:

“原生JS手写丝滑流畅的元素拖拽效果” 的相关文章

Lindroid开源应用:在安卓手机 / 平板上安装 Linux发行版

IT之家 6 月 19 日消息,Erfan Abdi 本月发布了 Lindroid 开源应用程序,让用户可以在安卓手机上安装 GNU / Linux 发行版,在完全支持手机硬件的情况下可以运行 Linux 应用程序。Lindroid 开源应用程序就是将 Linux 放入容器中,使用 Halium 等...

vue3中父子传值、defineProps用法、defineEmits用法

Vue3中新增了一个 script setup 语法糖模式,可以在单文件组件中更简洁地编写组件逻辑。使用 script setup 语法后,props、data、computed、methods 等选项不再需要独立定义,而是可以直接在 setup 函数中声明,代码结构更加清晰,并且可以更方便地使用响...

手把手教你Vue之父子组件间通信实践讲解【props、$ref 、$emit】

组件是 vue.js 最强大的功能之一,而组件实例的作用域是相互独立的,这就意味着不同组件之间的数据无法相互引用。那么组件间如何通信,也就成为了vue中重点知识了。这篇文章将会通过props、$ref和 $emit 这几个知识点,来讲解如何实现父子组件间通信。转载链接:https://www.jia...

vue组件间的九种通信方式

前言Vue组件实例间的作用域是相互独立的,而通常一个页面是由很多个组件构成,这些组件可能又嵌套了组件,形成了一个关系网图,它们的关系可能是像下图中一样,大致分为两种使用场景,父子组件间通信和非父子组件间通信,父子组件间通信又分为直接父子关系和间接父子关系。vue提供了多种通信方法,针对不同的通信需求...

有效地简化导航-Part 1:信息架构

「四步走」——理想的导航系统要做一个可用的导航系统,网页设计师必须按顺序回答以下4个问题:1. 如何组织内容?2. 如何解释导航的选项?3. 哪种导航菜单最适合容纳这些选项?4. 如何设计导航菜单?前两个问题关注构建和便签内容,通常称为信息架构。信息架构师通常用网站地图(site map diagr...

USB电池充电基础:应急指南

USB为便携设备供电与其串行通信功能一样,已经成为一种标准应用。如今,USB 供电已经扩展到电池充电、交流适配器及其它供电形式的应用。应用的普及带来的一个显著效果是便携设备的充电和供电可以互换插头和适配器。因此,相对于过去每种装置都采用专用适配器的架构相比,目前的解决方案允许采用多种电源进行充电。毋...