React 中使用拖拽

10,423 阅读5分钟

前言

在 HTML5 还未普及之前,实现拖拽的大致思路是监听鼠标移动相关事件,来拖动目标元素到页面的任意位置,这要求目标元素必须满足绝对定位、脱离文档流才可以被移动,伪代码如下:

box.onmousedown = (event) => {
  // 记录起始位置
  document.onmousemove = (event) => {
    // 计算移动位置,移动目标元素
  }
  document.onmouseup = () => {
    // 松开鼠标,结束移动
  }
}

HTML5 标准出来后,为标签元素新增了拖拽 draggable 属性,有了这个属性,我们可以通过监听元素的拖拽事件就能实现各种拖放功能。

默认情况下图片和链接可以被拖动,其他元素需要像下面这样,设置 draggable 属性:

<div draggable="true" id="draggableBox"></div>

下面我们先了解一下拖拽事件。

拖拽事件

拖拽事件涉及到两个步骤:拖起、放下,事件可以分为两类:

  • 一类是被拖起的元素所触发的事件,即拖起事件;
  • 一类则是拖起放下后,所在区域的目标元素触发的事件,即拖放事件。

拖起事件(拖拽元素)

拖拽元素 自身的事件。

  • ondragstart:当鼠标按下并且开始移动拖拽元素后,触发此事件,整个拖拽周期只触发一次;
  • ondrag:拖拽过程中会不断触发此事件;
  • ondragend:鼠标松开结束拖拽后,会触发此事件的执行,整个拖拽周期只触发一次。
const draggableBox = document.getElementById('draggableBox');

draggableBox.ondragstart = function (event) {
  console.log('开始拖拽');
}
draggableBox.ondrag = function (event) {
  console.log('拖拽中');
}
draggableBox.ondragend = function (event) {
  console.log('拖拽结束');
}

拖放事件(目标元素)

当拖拽的元素拖到一个目标元素上时,目标元素 会触发以下事件:

  • ondragenter:拖拽元素移动到目标元素时,触发此事件;
  • ondragover:拖拽元素停留在目标元素时,会持续触发此事件;
  • ondragleave:拖拽元素离开目标元素时(没有在目标元素上放下),触发此事件;
  • ondrop:拖拽元素在目标元素放下(松开鼠标),触发此事件。
const target = document.getElementById('target');
target.ondragenter = function (event) {
  console.log('进入目标元素');
}
target.ondragover = function (event) {
  event.preventDefault();
  console.log('在目标元素中拖拽');
}
target.ondragleave = function (event) {
  console.log('拖放离开目标元素');
}
target.ondrop = function (event) {
  console.log('在目标元素中拖放');
}

注意:目标元素默认是不能够被拖放的,即不会触发 ondrop 事件,可以通过在目标元素的 ondragover 事件中取消默认事件来解决此问题。

拖拽状态(视图体现)

当我们拖拽起元素时,可以为拖拽元素添加一些特定样式来让用户知道,当前他对哪个元素进行了拖拽,这在一组列表中来拖拽 item 非常需要。

我们可以在事件源 event 中拿到元素节点,设置样式如下:

const draggableBox = document.getElementById('draggableBox');

draggableBox.ondragstart = function (event) {
  console.log('开始拖拽');
  event.target.classList.add('draggable-style');
}
draggableBox.ondragend = function (event) {
  console.log('拖拽结束');
  event.target.classList.remove('draggable-style');
}

数据交换

有时候,我们在拖起元素时,需要携带一些数据,在拖放到目标元素后,将数据交给目标元素做处理。HTML5 拖拽 提供了数据交换的方式:event.dataTransfer

const draggableBox = document.getElementById('draggableBox');
const target = document.getElementById('target');

draggableBox.ondragstart = function (event) {
  console.log('开始拖拽');
  event.dataTransfer.setData('value', '数据传递');
}

// ...

target.ondrop = function (event) {
  console.log('在目标元素中拖放');
  const value = event.dataTransfer.getData('value');
}

注意event.dataTransfer.setData 默认只能传递 string 类型数据,如果是一个对象,可以使用 JSON.stringify 转换。

自定义原生拖动图像(拖拽效果)

原生默认的拖拽效果是拖拽元素的一个半透明的预览图,有时候我们需要自定义拖拽时的预览图来实现我们的需求,HTML5 拖拽 也提供了自定义方式:

语法如下:

event.dataTransfer.setDragImage(element, xOffset, yOffset);

使用示例:

const draggableBox = document.getElementById('draggableBox');
const target = document.getElementById('target');

draggableBox.ondragstart = function (event) {
  console.log('开始拖拽');
  e.dataTransfer.setDragImage(document.getElementById('customEle'), 0, 0);
}

上面代码中我们将 customEle 元素 作为拖动图像,拖动图像的显示位置与鼠标指针看齐。

注意:我们自定义的元素必须是一个已经挂载到 DOM 树上的元素,否则自定义图像会失败,这一点在 React、Vue 框架中要特别注意,虚拟 DOM 对象 用在这里不生效。

在 React 中使用:

有了上面的基础,我们就可以很容易在 React 中去使用拖拽。

拖拽元素

场景:一组 list 列表中,每一个 item 都作为一个被拖拽的元素。

DOM 结构:

{list.map((item) => (
  <li 
    draggable={item.allowMove ? true : false}
    onDragStart={e => handleDragStart(e, item)}
    onDragEnd={handleDragEnd}>
    // ...
  </li>
)

// 自定义拖动图像
<div 
  ref={draggableView} 
  style={{ display: 'none' }} 
  className="gl-draggable-preview">
  // ...
</div>

方法定义:

const draggableView = useRef<HTMLDivElement>(null); // 拖拽图像
const [draggableData, setDraggableData] = useState({}); // 拖拽项的数据

// 获取拖拽图像的容器盒子
export const getDraggablePreview = () => {
  let previewEle = document.querySelector('#draggable-preview'); // 要拖动渲染的预览图
  if (!previewEle) {
    previewEle = document.createElement('div');
    previewEle.id = 'draggable-preview';
    document.body.appendChild(previewEle);
  }
  return previewEle;
}
  
const handleDragStart = (e, data) => {
  console.log('开始拖拽');
  // 保存当前 item 数据,用于在自定义图片中展示
  setDraggableData(data);
  
  // 为拖拽的元素添加拖拽 class 样式
  e.target.classList.add('list-item-draggable');

  // 从 body 上获取(没有则创建)用于存放自定义拖动图像的盒子
  const previewBox = getDraggablePreview();
  // 拖动图像默认是隐藏的,让其显示
  draggableView.current!.style.display = 'inline-flex';
  // 将拖动图像添加到 DOM 树上的盒子中
  previewBox.appendChild(draggableView.current as Node);

  // 将 DOM 树上的拖动图像作为我们拖拽时显示的图像
  e.dataTransfer.setDragImage(previewBox, 0, 0);
  
  // 数据交换
  e.dataTransfer.setData('data', JSON.stringify({ id: data.id, type: data.type }));
}

const handleDragEnd = (e) => {
  console.log('结束拖拽');
  // 移除拖拽 class 样式
  e.target.classList.remove('list-item-draggable');
  
  const previewBox = getDraggablePreview();
  // removeChild,会将自定义图像从 previewBox 中移除,并且 draggableView.current 会置为 null
  previewBox.removeChild(draggableView.current);
}

目标元素

DOM 结构:

<div
  onDragEnter={handleDragEnter}
  onDragLeave={handleDragLeave}
  onDragOver={handleDragover}
  onDrop={handleDrop}
  >
  // ...
</div>

方法定义:

const handleDragEnter = (e) => {
  console.log('进入目标元素', e.target.id);
  // 添加拖放图像移动到目标元素的 class 样式
  target.classList.add('target-draggable');
}

const handleDragLeave = (e) => {
  console.log('拖放离开目标元素');
  target.classList.remove('target-draggable');
}

const handleDragover = (e) => {
  e.preventDefault();
}

const handleDrop = async (e, id) => {
  target.classList.remove('target-draggable');
  // 获取携带数据
  const data = JSON.parse(e.dataTransfer.getData('data'));
  // ...
}

文末

本文在编写中如有不足的地方,👏欢迎读者提出宝贵意见。

参考:js 原生拖拽的两种方法DataTransfer