React 实现简易的图片拖动排序

14,045 阅读3分钟

基本介绍 📝

  • 在 web 页面中,如果需要改变多个元素的位置,可以通过元素拖动来实现。HTML5中加入了一个全局属性draggable,通过设置该属性的值为 true/false 来控制元素是否可拖动。需要注意的是:
    • 链接和图片默认是可拖动的,可以通过将 draggable 设置为false将他们变为不可拖动元素。
    • Internet Explorer 8 及更早 IE 版本不支持 draggable 属性。
  • 本文图片拖拽排序的实现主要利用了元素在拖放的过程中会触发的 ondragstartondragendondragover 事件,具体触发时机和其他事件可以参考 MDN,碍于篇幅,本文将不再进行阐述。

效果演示 🤩

  • 先看效果:
  • 下面将介绍思路和具体的实现代码。

思路分析 🤔

  • 本文图片拖拽排序的实现主要利用了元素在拖放的过程中会触发的 ondragstartondragendondragover 事件。
  • 通过监听 ondragstart 事件,将当前被拖动元素的数据保存下来,并给其增加特殊样式(设置元素的透明度和放大元素)。
  • 通过监听 ondragend 事件,将上面一步设置在被拖动元素的特殊样式删除。为了减少内存消耗,我们把被拖动元素的 ondragend 事件委托到最外层容器(事件委托)
  • 实现最重要的拖动排序功能,主要是为元素绑定 ondragover 事件。当 ondragover 事件被触发时,需要获取当前鼠标的位置(event.clientX, event.clientY),计算出当前鼠标拖动到哪个元素上通过判断当前被拖动元素和其他元素的位置,从而实现元素的交换排序。
  • 下面小节将介绍具体的实现代码。

具体实现 🧐

基本布局

  • 在实现拖拽功能之前,先完成基本布局:
/** index.tsx */
import React, { useMemo, useState } from 'react';

import styles from './index.module.scss';

//  每行多少列
const COLUMN = 4;
//  每个元素宽度
const WIDTH = 120;
//  每个元素高度
const HEIGHT = 80;
// 图片左右 padding
const IMAGE_PADDING = 5;

const showList = [
  {
    id: 2,
    name: 'osmo pocket',
    image:
      'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1605703865983&di=a35a43a3b9e866f1ee0048563bfd2577&imgtype=0&src=http%3A%2F%2Fpic.rmb.bdstatic.com%2F5d8f2523322e3f4de91021701e95182c.jpeg',
  },
  {
    id: 4,
    name: 'mavic pro',
    image:
      'https://ss1.bdstatic.com/70cFvXSh_Q1YnxGkpoWK1HF6hhy/it/u=787082346,3090178555&fm=15&gp=0.jpg',
  },
  {
    id: 1,
    name: 'mavic mini2',
    image:
      'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1605703111703&di=59fa621eb1e7f8f4285b95df80e11fd0&imgtype=0&src=http%3A%2F%2Fp1.itc.cn%2Fimages01%2F20201105%2F600892c32d524b99a118ea56cdf3c211.png',
  },
  {
    id: 3,
    name: '机甲大师s1',
    image:
      'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1605703133913&di=a415583ce97dd0a34efe17cac24a97ab&imgtype=0&src=http%3A%2F%2F5b0988e595225.cdn.sohucs.com%2Fimages%2F20200325%2F64ebb68f1125450f91e64bb34dc19d55.jpeg',
  },
  {
    id: 0,
    name: 'mavic 2',
    image:
      'https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=4132295553,3011440949&fm=26&gp=0.jpg',
  },
];

const DragAndDropPage: React.FC = () => {
  const [list, setList] = useState(showList);

  // IMPORTANT: 动画需要,需要保持一定的渲染顺序
  const sortedList = useMemo(() => {
    return list.slice().sort((a, b) => {
      return a.id - b.id;
    });
  }, [list]);

  const listHeight = useMemo(() => {
    const size = list.length;
    return Math.ceil(size / COLUMN) * HEIGHT;
  }, [list]);

  return (
    <div 
      className={styles.wrapper} 
      style={{ width: COLUMN * (WIDTH + IMAGE_PADDING) + IMAGE_PADDING }}
    >
      <ul className={styles.list} style={{ height: listHeight }}>
        {sortedList.map((item) => {
          const index = list.findIndex((i) => i === item);
          const row = Math.floor(index / COLUMN);
          const col = index % COLUMN;
          return (
            <li
              key={item.id}
              className={styles.item}
              style={{
                height: HEIGHT,
                left: col * (WIDTH + IMAGE_PADDING),
                top: row * HEIGHT,
                padding: `0 ${IMAGE_PADDING}px`
              }}
              data-id={item.id}
            >
              <img src={item.image} alt={item.name} width={WIDTH} />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default React.memo(DragAndDropPage);
  • 样式文件的内容如下:
/** index.module.scss */
.wrapper {
  overflow: hidden;
  margin: 100px auto;
  padding: 10px 0;
  background: #fff;
  border-radius: 5px;
}

.list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 0;

  position: relative;
}

.item {
  position: absolute;
  display: flex;
  align-items: center;
  transition: all 0.2s ease-in-out;
}

效果如下:

  • 通过上述代码可以看到,尽管展示的 li 元素看起来像是按照 list 原始顺序展示,但是实际上其渲染顺序是由排序后的列表 sortedList 决定的。

之所以表现像是按照 list 原始顺序展示,是通过在 sortedList.map 时根据当前 itemlist 数组的索引 index 计算出其CSS的 lefttop 属性。

拖拽实现

拖动增加特殊样式

  • 首先,为最外层的容器 wrapper 绑定 ref 属性,方便在之后的事件方法中获取到该 DOM 节点,并将每个 li 元素的 draggable 属性的值设置为 true/false 使其变为可拖动元素,同时绑定 ondragstart 事件:
  • handleDragStart 方法主要是将当前被拖动元素的数据保存下来,并给其增加特殊样式(设置元素的透明度和放大元素),相关代码如下:
  • 此时效果如下:

拖放结束移除特殊样式

  • 可以看到,当开始拖动元素时,元素应用了特殊样式,但是松开鼠标后元素没恢复原来的样式。这时候就需要在拖动结束后删除前面添加的特殊样式。
  • 为了减少内存消耗,我们把被拖动元素的 ondragend 事件委托到最外层容器(事件委托),相关代码如下:
  • 此时效果如下:

拖动排序

  • 接下来将实现最重要的拖动排序功能,主要是为元素绑定 ondragover 事件。
  • 默认情况下,数据/元素不能放置到其他元素中。如果要实现该功能,我们需要防止元素的默认处理方法。我们可以通过调用 event.preventDefault() 方法来实现 ondragover 事件。
  • ondragover 事件被触发时,需要获取当前鼠标的位置(event.clientX, event.clientY),计算出当前鼠标拖动到哪个元素上通过判断当前被拖动元素和其他元素的位置,实现元素的交换排序,关键的是实现 updateList 方法,相关代码如下:
/** 将某元素插入到数组中的某位置 */
export function insertBefore<T>(list: T[], from: T, to?: T): T[] {
  const copy = [...list];
  const fromIndex = copy.indexOf(from);
  if (from === to) {
    return copy;
  }
  copy.splice(fromIndex, 1);
  const newToIndex = to ? copy.indexOf(to) : -1;
  if (to && newToIndex >= 0) {
    copy.splice(newToIndex, 0, from);
  } else {
    // 没有 To 或 To 不在序列里,将元素移动到末尾
    copy.push(from);
  }
  return copy;
}

/** 判断是否数组相等 */
export function isEqualBy<T>(a: T[], b: T[], key: keyof T) {
  const aList = a.map((item) => item[key]);
  const bList = b.map((item) => item[key]);
  
  let flag = true;
  aList.forEach((i, idx) => {
    if (i !== bList[idx]) {
      flag = false
    }
  })
  return flag;
}

const DragAndDropPage: React.FC = () => {
  const [list, setList] = useState(showList);
  const dragItemRef = useRef<ListItem>();
  const dropAreaRef = useRef<HTMLDivElement>(null);

  ...

  const updateList = useCallback(
    (clientX: number, clientY: number) => {
      const dropRect = dropAreaRef.current?.getBoundingClientRect();
      if (dropRect) {
        const offsetX = clientX - dropRect.left;
        const offsetY = clientY - dropRect.top;
        const dragItem = dragItemRef.current;
        // 超出拖动区域
        if (
          !dragItem ||
          offsetX < 0 ||
          offsetX > dropRect.width ||
          offsetY < 0 ||
          offsetY > dropRect.height
        ) {
          return;
        }

        const col = Math.floor(offsetX / WIDTH);
        const row = Math.floor(offsetY / HEIGHT);
        let currentIndex = row * COLUMN + col;
        const fromIndex = list.indexOf(dragItem);
        if (fromIndex < currentIndex) {
          // 从前往后移动
          currentIndex++;
        }
        const currentItem = list[currentIndex];

        const ordered = insertBefore(list, dragItem, currentItem);
        if (isEqualBy(ordered, list, 'id')) {
          return;
        }
        setList(ordered);
      }
    },
    [list]
  );

  const handleDragOver = useCallback(
    (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      updateList(e.clientX, e.clientY);
    },
    [updateList]
  );

  return (
    <div
      className={styles.wrapper}
      ref={dropAreaRef}
      style={{ width: COLUMN * (WIDTH + IMAGE_PADDING) + IMAGE_PADDING }}
      onDragEnd={handleDragEnd}
      onDragOver={handleDragOver}
    >
      <ul className={styles.list} style={{ height: listHeight }}>
        {sortedList.map((item) => {
          const index = list.findIndex((i) => i === item);
          const row = Math.floor(index / COLUMN);
          const col = index % COLUMN;
          return (
            <li
              draggable
              key={item.id}
              className={styles.item}
              style={{
                height: HEIGHT,
                left: col * (WIDTH + IMAGE_PADDING),
                top: row * HEIGHT,
                padding: `0 ${IMAGE_PADDING}px`,
              }}
              data-id={item.id}
              onDragStart={(e) => handleDragStart(e, item)}
            >
              <img src={item.image} alt={item.name} width={WIDTH} />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default React.memo(DragAndDropPage);
  • 效果如下:

完整代码

  • index.tsx文件中的内容:
  • 样式文件内容:

总结 👀

  • 本文介绍了如何在 React 项目中实现简易的图片拖动排序。主要是通过给需要拖动的元素设置 draggable 属性,并监听相关的事件,进行样式增减、拖动排序等。

以上内容如有遗漏错误,欢迎留言 ✍️指出,一起进步💪💪💪

如果觉得本文对你有帮助,🏀🏀留下你宝贵的 👍

参考资料

  1. React hook
  2. HTML 拖放 API
  3. 用两种不同的姿势来实现拖动排序