jjzjj

原生拖拽太拉跨了,纯JS自己手写一个拖拽效果,纵享丝滑

茶无味的一天 2023-03-28 原文

前言

提到元素拖拽,通常都会先想到用 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() }

体验优化

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

<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bc90a403f68641f68a0c0e2c1770e638~tplv-k3u1fbpfcp-watermark.image?" alt="2022-09-02 17.01.36.gif" width="90%" />

我的优化思路是利用浏览器加载过同一张图片就会优先读缓存的机制,先用一个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一天(公众号: 品味前端),期待与你共同成长~

有关原生拖拽太拉跨了,纯JS自己手写一个拖拽效果,纵享丝滑的更多相关文章

  1. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  2. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  3. ruby-on-rails - 渲染另一个 Controller 的 View - 2

    我想要做的是有2个不同的Controller,client和test_client。客户端Controller已经构建,我想创建一个test_clientController,我可以使用它来玩弄客户端的UI并根据需要进行调整。我主要是想绕过我在客户端中内置的验证及其对加载数据的管理Controller的依赖。所以我希望test_clientController加载示例数据集,然后呈现客户端Controller的索引View,以便我可以调整客户端UI。就是这样。我在test_clients索引方法中试过这个:classTestClientdefindexrender:template=>

  4. ruby-on-rails - 如果 Object::try 被发送到一个 nil 对象,为什么它会起作用? - 2

    如果您尝试在Ruby中的nil对象上调用方法,则会出现NoMethodError异常并显示消息:"undefinedmethod‘...’fornil:NilClass"然而,有一个tryRails中的方法,如果它被发送到一个nil对象,它只返回nil:require'rubygems'require'active_support/all'nil.try(:nonexisting_method)#noNoMethodErrorexceptionanymore那么try如何在内部工作以防止该异常? 最佳答案 像Ruby中的所有其他对象

  5. ruby - 为什么 SecureRandom.uuid 创建一个唯一的字符串? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭8年前。Improvethisquestion为什么SecureRandom.uuid创建一个唯一的字符串?SecureRandom.uuid#=>"35cb4e30-54e1-49f9-b5ce-4134799eb2c0"SecureRandom.uuid方法创建的字符串从不重复?

  6. ruby-on-rails - Rails - 从另一个模型中创建一个模型的实例 - 2

    我有一个正在构建的应用程序,我需要一个模型来创建另一个模型的实例。我希望每辆车都有4个轮胎。汽车模型classCar轮胎模型classTire但是,在make_tires内部有一个错误,如果我为Tire尝试它,则没有用于创建或新建的activerecord方法。当我检查轮胎时,它没有这些方法。我该如何补救?错误是这样的:未定义的方法'create'forActiveRecord::AttributeMethods::Serialization::Tire::Module我测试了两个环境:测试和开发,它们都因相同的错误而失败。 最佳答案

  7. ruby - 用 Ruby 编写一个简单的网络服务器 - 2

    我想在Ruby中创建一个用于开发目的的极其简单的Web服务器(不,不想使用现成的解决方案)。代码如下:#!/usr/bin/rubyrequire'socket'server=TCPServer.new('127.0.0.1',8080)whileconnection=server.acceptheaders=[]length=0whileline=connection.getsheaders想法是从命令行运行这个脚本,提供另一个脚本,它将在其标准输入上获取请求,并在其标准输出上返回完整的响应。到目前为止一切顺利,但事实证明这真的很脆弱,因为它在第二个请求上中断并出现错误:/usr/b

  8. ruby - 一个 YAML 对象可以引用另一个吗? - 2

    我想让一个yaml对象引用另一个,如下所示:intro:"Hello,dearuser."registration:$introThanksforregistering!new_message:$introYouhaveanewmessage!上面的语法只是它如何工作的一个例子(这也是它在thiscpanmodule中的工作方式。)我正在使用标准的ruby​​yaml解析器。这可能吗? 最佳答案 一些yaml对象确实引用了其他对象:irb>require'yaml'#=>trueirb>str="hello"#=>"hello"ir

  9. ruby - Rails 关联 - 同一个类的多个 has_one 关系 - 2

    我的问题的一个例子是体育游戏。一场体育比赛有两支球队,一支主队和一支客队。我的事件记录模型如下:classTeam"Team"has_one:away_team,:class_name=>"Team"end我希望能够通过游戏访问一个团队,例如:Game.find(1).home_team但我收到一个单元化常量错误:Game::team。谁能告诉我我做错了什么?谢谢, 最佳答案 如果Gamehas_one:team那么Rails假设您的teams表有一个game_id列。不过,您想要的是games表有一个team_id列,在这种情况下

  10. ruby - 将一个超薄文件包含在另一个超薄文件中 - 2

    我在一个静态网站上工作(因此没有真正的服务器支持),我想在另一个网站中包含一个小的细长片段,可能会向它传递一个变量。这可能吗?在rails中很容易,虽然是render方法,但我不知道如何在slim上做(显然load方法不适用于slim)。 最佳答案 Slim包含Include插件,允许在编译时直接在模板文件中包含其他文件:require'slim/include'includepartial_name文档可在此处获得:https://github.com/slim-template/slim/blob/master/doc/incl

随机推荐