前言: 拖拽组件是在前端开发中十分常见的一个功能,现在无论你是使用React还是Vue,都有很多现成的拖拽组件可以使用。不过,有些时候你可能还是需要自己去实现,那么就必须需要理解其实现原理。
背景: 这周接了个任务, 将antD的穿梭框做成可拖拽的(左右都可拖拽, 且支持多选拖拽)。看了下antD的api, 发现没有配置, 就决定自己写个原生实现了, 借鉴了大佬的demo, 看了一下拖拽是如何实现的, 再应用在项目中了。
了解HTML的拖拽
现如今,大部分的前端拖拽组件都依托于HTML5原生提供的拖放接口。那么在开始用具体框架来封装组件的之前,就需要搞清楚这些原生的接口功能。
HTML 5的DOM鼠标事件中添加了drag这个事件。对于一个设置了draggable属性的页面元素来说,只要将其拖动到一个同样带有droppable属性的元素上,就算完成了一次完整的拖放功能。在这一过程中,会分别触发一些如下事件类型:
提示: 链接和图片默认是可拖动的,不需要 draggable 属性。
draggable有三个值,如下所示:
draggable = true(元素可以被拖动)
draggable = false(元素不能被拖动)
draggable = auto(浏览器可以自主决定某个元素是否可以被拖动)
被拖拽元素触发的事件 (源元素):
* ondragstart - 当一个元素开始被拖拽的时候触发 (按下鼠标键并开始移动鼠标的时候触发dragstart事件)
* ondrag - 元素正在拖动时触发 (在元素被拖动期间会持续触发drag事件, 与mousemove和touchmove事件类似)
* ondragend - 用户完成元素拖动后触发, 当拖动停止时 (无论把元素放到了有效的放置目标,还是放到了无效的放置目标上),都会发生dragend事件。
说明: 默认情况下,浏览器不会再拖动期间改变被拖动元素的外观。但是可以自行修改。不过,大多数浏览器会为正被拖动的元素创建一个半透明的副本,这个副本始终跟随光标移动。当某个元素被拖动到一个有效的放置目标的时候, 会触发下列事件:
释放拖拽元素时触发的事件(目标元素):
* ondragenter - 当被鼠标拖动的对象进入其容器范围内时触发此事件 (类似于mouseover事件)
* ondragover - 当被鼠标拖动的对象移动经过一个元素时触发(会连续触发dragover事件, 每100毫秒触发一次, 类似于mousemove事件)
* ondragleave - 当被鼠标拖动的对象离开其容器范围内时触发 (类似于mouseout事件)
* ondrop - 在拖拽操作结束释放时于释放元素上触发
需要注意:dragenter,dragover(dragend)事件下我们需要阻止浏览器的默认行为,让我们拖拽的元素成为可释放的元素。
熟悉这些基本事件类型后,实现上就是在源对象和目标对象上分别绑定对应的事件处理函数,并监听处理即可。
除了这些拖放的事件接口外,我们通常还需要处理数据的传递。HTML5中同样提供了简便的接口,在对应的监听函数内,我们可以拿到event对象,在这个对象内部有个DataTransfer接口,可专门用来保存事件的数据内容。DataTransfer对应的方法有:
拖拽携带的数据处理
* event.dataTransfer.setData(format, data) 添加拖拽数据,这个方法接收两个参数,第一个参数是数据类型(可自定义, 只能填入类似“text/plain”或“textml”的表示 MIME类型的文字),第二个参数是要携带的数据;
* event.dataTransfer.getData(format) 反向操作,获取数据,只接收一个参数,即数据类型;
* event.dataTransfer.clearData(format) 清除数据;从dataTransfer对象中删除指定格式的数据,参数可选,若不给参数,将删除对象中所有的数据。
* event.dataTransfer.setDragImage(el, x, y) 可自定义拖放过程中鼠标旁边的图像; 设置拖放操作的图标,其中el代表自定义图标,x代表图标与鼠标在水平方向上的距离,y代表图标与鼠标在垂直方向上的距离
下面就是一个简单的demo了~
import React, { useState, useRef } from 'react';
const list = [
{
uid: '1',
text: '序列1',
},
{
uid: '2',
text: '序列2',
},
{
uid: '3',
text: '序列3',
},
{
uid: '4',
text: '序列4',
},
{
uid: '5',
text: '序列5',
},
];
const Drag: React.FC<{}> = () => {
// console.log('list', list)
const [rightList, setRightList] = useState(list);
const [leftList, setLeftList] = useState([]);
//鼠标华划过接受拖拽元素的事件
const handleDrop = (callBack, e, arrow) => {
e.preventDefault(); //阻止默认事件:防止打开拖拽元素的url(Firefox)
console.log('handleDrop', callBack, e, arrow);
const {
dataset: { id },
} = e.target;
console.log('id', id);
const curData = JSON.parse(e.dataTransfer.getData('itemData'));
console.log('curData', curData);
callBack((prevData) => {
console.log('prevData', prevData);
const diffData = prevData.filter((item) => item.uid !== curData.uid);
// id 不存在是在不同盒子内拖拽 存在则是在本身盒子内拖拽
// 项目中发现, 只要鼠标经过元素, 且元素被自定义过data-id属性, id都可以都能拿到!
if (!id) return [...diffData, curData];
// 找到鼠标划过的目标元素的其盒子内的位置
const index = diffData.findIndex((item) => item.uid === id);
//把拖拽元素放置在鼠标划过元素的上方
diffData.splice(index, 0, curData);
return diffData;
});
//朝左拖拽
if (arrow === 'left') {
setRightList((prvData) => prvData.filter((item) => item.uid !== curData.uid));
}
// 朝右拖拽
else {
setLeftList((prvData) => prvData.filter((item) => item.uid !== curData.uid));
}
};
// 拖拽元素进入目标元素时触发事件-为目标元素添加拖拽元素进入时的样式效果
const handleDragEnter = (e) => e.target.classList.add('over');
// 拖拽元素离开目标元素时触发事件-移除目标元素的样式效果
const handleDragLeave = (e) => e.target.classList.remove('over');
return (
<div>
<div
style={{ width: '300px', height: '300px' }}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => handleDrop(setLeftList, e, 'left')}
>
{leftList.map((item) => (
<div
className="item"
draggable
key={item.uid}
data-id={item.uid}
onDragStart={(e) => {
e.dataTransfer.setData('itemData', JSON.stringify(item));
}}
>
{item.text}
</div>
))}
</div>
<div
style={{ width: '300px', height: '300px' }}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => handleDrop(setRightList, e, 'right')}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
{rightList.map((item) => (
<div
className="item"
draggable
key={item.uid}
data-id={item.uid}
onDragStart={(e) => {
e.dataTransfer.setData('itemData', JSON.stringify(item));
}}
>
{item.text}
</div>
))}
</div>
</div>
);
};
export default Drag;
额外的样式处理 (如需要自定义样式处理时再看)
添加拖放效果
要实现拖放的视觉效果,需要effectAllowed和dropEffect两个属性结合起来使用。
dropEffect:设置拖放目标允许发生的拖放行为,如果此处设置的拖放行为不在effectAllowed属性设置的可拖放行为内,拖放操作将会失败。该属性值只允许为"null"、"copy"、"link"或"move";
effectAllowed:设置拖动元素允许发生的拖动行为,该属性值可为"none"、"copy"、"copyLink"、"copyMove"、"link"、"linkMove"、"move"、"all"或"uninitialized";
// 先设置一些效果常量:(内置属性)
// 可以通过props传递
export const All = "all";
export const Move = "move";
export const Copy = "copy";
export const Link = "link";
export const CopyOrMove = "copyMove";
export const CopyOrLink = "copyLink";
export const LinkOrMove = "linkMove";
export const None = "none";
// 被拖拽组件
const Drag = (props) => {
// 如果想自定义拖拽图像(非必须)
const [isDragging, setIsDragging] = React.useState(false);
const image = React.useRef(null);
React.useEffect(() => {
image.current = null;
if (props.dragImage) {
image.current = new Image();
image.current.src = props.dragImage;
}
}, [props.dragImage]);
// 拖拽开始
const startDrag = e => {
setIsDragging(true);
// 设置数据(必须)
e.dataTransfer.setData("drag-item", props.dataItem);
// 设置effectAllowed属性添加效果(非必须)
e.dataTransfer.effectAllowed = props.dropEffect;
// 设置图片(非必须)
if (image.current) {
e.dataTransfer.setDragImage(image.current, 0, 0);
}
};
// 拖拽结束
const dragEnd = () => setIsDragging(false);
return (
{props.children}
);
}
// 目标组件
const DropTarget = (props) => {
// 滑过时
const dragOver = e => {
// 阻止默认行为
e.preventDefault();
// 添加效果(非必须)
e.dataTransfer.dropEffect = props.dropEffect;
}
// 进入时(非必须)
const dragEnter = e => {
e.dataTransfer.dropEffect = props.dropEffect;
}
// 释放时
const drop = e => {
// 获取数据(必须)
const droppedItem = e.dataTransfer.getData("drag-item");
// 触发回调函数(必须)
if (droppedItem) {
props.onItemDropped(droppedItem);
}
}
return (
{props.children}
)
}
// 样式(可以根据isDragging来进行判断)
const draggingStyle = {
opacity: 0.25,
};
【浏览器支持】
目前只有Internet Explorer 9、Firefox、Opera 12、Chrome 以及 Safari5支持拖放,在 Safari5.1.2 中不支持拖放。
关于自定义放置目标以及浏览器兼容性的一点说明
在拖动元素经过某些无效放置目标的时候,可以看到一种特殊鼠标手势(圆环中一条反斜线),表示不能放置。虽然所有元素都支持放置目标事件,但是这些元素默认是不允许放置的。如果拖动元素经过不允许放置的元素,无论用户如何操作,都不会发生drop事件。不过,你可以把任何元素变成有效的放置目标,方法是重写dragenter和dragover事件的默认行为(event.preventDefault)。
重写了默认行为之后,就会发现当拖动着元素移动到放置目标上的时候,光标变成允许放置的符号。在Firefox 3.5+中,放置事件的默认行为是打开被放到放置目标上的url。如果是把图像拖到放置目标上,页面就会转向图像文件。如果是把文本拖放到放置目标上,则会导致无效url错误。所以为了让Firefox支持政正常的拖放,还要取消drop事件的默认行为,阻止打开拖拽元素的URL。
收获:
1.原生H5拖拽事件的学习, 还有其他原生事件的熟悉
2. react Hooks+ TS的熟悉
3. 拖拽与数据的禁用处理逻辑
不足:
1. 多选拖拽的样式还是没有实现
2.对原生事件还是不够熟悉
参考: https://blog.csdn.net/weixin_45750771/article/details/125546827
https://blog.csdn.net/weixin_35521120/article/details/113522235