React+H5拖放api 实现拖放功能

3,211 阅读9分钟

记录工作中的思考🤔

背景

公司web项目有个需求,需要实现在列表内和列表之间进行元素拖放排序的功能。

项目是使用react开发的,所以起初是想找一个react拖放插件来实现,于是百度了几个拖放插件,光看了看用法就挺麻烦的(说实话,也没太仔细看)。因为需要的不只是一个拖放的展示效果,还需要收集到拖放后的新数据做逻辑,感觉用插件会复杂的多,于是,用h5的拖放api搞起来吧。

介绍一下h5 拖放api

有拖有放才叫拖放(开始我还一直错误的叫拖拽功能 拖拽api,有这个解释后,清晰明了)

【拖】方:

【属性】: draggable

设置元素为可拖放

首先,为了使元素可拖动,给元素设置draggable属性,并且把 draggable 属性值设置为 true

【事件】

  1. ondragstart 按下鼠标键并开始移动时触发(对象是拖拽元素)
  2. ondrag 在元素拖拽过程中持续触发(对象是拖拽元素)
  3. ondragend 元素拖拽停止时触发(对象是拖拽元素)

【放】方:

元素被拖动到有效的放置目标时,下列事件会依次发生: 【事件】

  1. ondragenter:当拖拽对象进入投放区时触发(对象是目标元素)
  2. ondragover :拖拽对象在投放区内移动时持续触发(对象是目标元素)
  3. ondragleave:元素离开投放区时触发(对象是目标元素)
  4. ondrop :拖拽对象放置在投放区时触发(对象是目标元素)

ondragover 需要注意的是:默认地,无法将数据/元素放置到其他元素中。如果需要设置允许放置,我们必须阻止对元素的默认处理方式。这要通过调用ondragover 事件的 event.preventDefault() 方法。在ondragover中一定要执行 preventDefault()否则ondrop事件不会被触发

ondrop 需要注意的是 :调用 preventDefault() 来避免浏览器对数据的默认处理(drop 事件的默认行为是以链接形式打开)

还有一个dataTransfer 对象记录数据。因为react 有state或者是作为数据管理mobx或redux,就直接没用到。可以自行百度

拖拽过程

拖动元素的事件

当按住鼠标拖动draggable元素的时候会按照如下顺序依次触发

ondragstart -> ondrag -> ondragend

投放区事件

当将draggable元素元素拖动到容器中将会按照如下顺序依次触发

ondragenter -> ondragover -> drop

实现

之前在查资料过程中遇到了一遍文章,然后接下来的实现样式和部分思路是来源于原链接

只有拖放没有排序

效果:

/**
* 主js
主要思路就是:
1、使用【拖】方方法 ondragstart  ondragend  记录拖拽元素的信息
2、使用【接收方】方法 ondragenter ondragleave  记录接收区域的信息
3、ondrop 放置的时候,开始处理对应区域的数组内容
*/
import React, { Component } from 'react';
import TaskCol from './TaskCol.js';//接收区域
import TaskItem from './TaskItem.js';//接收区域的每一个可拖拽元素

const STATUS_TODO = 'STATUS_TODO';
const STATUS_DOING = 'STATUS_DOING';
const STATUS_DONE = 'STATUS_DONE';
//按照这样一个数据结构
const TASK_DATA = [{
  id: STATUS_TODO,
  title: '待处理',
  data: [{
    id: 1,
    status: STATUS_TODO,
    content: '写一个react拖拽实现记录',
  },
  {
    id: 2,
    status: STATUS_TODO,
    content: '搞懂redux中间件源码',
  }, {
    id: 3,
    status: STATUS_TODO,
    content: '学习react-router',
  }, {
    id: 4,
    status: STATUS_TODO,
    content: '学习css',
  }, {
    id: 5,
    status: STATUS_TODO,
    content: '学习算法',
  },
  {
    id: 6,
    status: STATUS_TODO,
    content: '前端学习之路真的是无止境',
  },
  {
    id: 7,
    status: STATUS_TODO,
    content: '有工作 也有生活才够好',
  },
  {
    id: 8,
    status: STATUS_TODO,
    content: '有生活又有乐趣才完美',
  },
  {
    id: 9,
    status: STATUS_TODO,
    content: '加油吧 你可以的',
  },
  ],
},
{
  id: STATUS_DOING,
  title: '进行中',
  data: [],
},
{
  id: STATUS_DONE,
  title: '已完成',
  data: [],
},
];

const defaultFrom = { // 记录拖方信息
  itemIndex: null, // 拖拽元素在当前所在区域列的位置
  colId: null, // 拖拽元素所在的区域列的id(唯一标识)
  item: null, // 拖拽元素的信息
};
const defaultTo = { // 记录接收方信息
  colId: null, // 接收区域列的id(唯一标识)
};

export default class Drag extends Component {
  constructor(props) {
    super(props);
    this.state = {
      tasks: TASK_DATA,
      from: defaultFrom,
      to: defaultTo,
    };
  }
  handleDragStart = (itemIndex, colId, item) => {
    this.setState({
      from: {
        itemIndex,
        colId,
        item,
      },
    });
  }
  handleDragEnd = () => {
    this.setState({
      from: {},
    });
  }

  /**
   * 接收方
   */
  handleDragEnter = (colId) => {
    this.setState({
      to: {
        colId,
      },
    });
  }

  handleDragLeave = () => {
    this.setState({
      to: defaultTo,
    });
  }

  handleDrop = () => {
    const tasks = this.state.tasks;
    const { from, to } = this.state;
    const { itemIndex, item } = from;
    if (from.colId === to.colId) { // 如果在元素所在的区域拖放,不做处理
      return;
    }

    tasks.forEach((taskItem) => { // 把原列表拖拽的元素删除,push到新列表里
      if (taskItem.id === from.colId) {
        taskItem.data.splice(itemIndex, 1);
      } else if (taskItem.id === to.colId) {
        taskItem.data.push(item);
      }
    });
    this.setState({
      tasks,
      from: defaultFrom,
      to: defaultTo,
    });
  }
  render() {
    const { tasks, from, to } = this.state;
    return (<div>
      <div style={{ display: 'flex', height: 400, width: 700 }}>
        {
          tasks.map(item => < TaskCol
            id={item.id}
            title={item.title}
            onDragEnter={this.handleDragEnter}
            onDragLeave={this.handleDragLeave}
            onDrop={this.handleDrop}
            active={from.itemIndex && from.colId !== to.colId && item.id === to.colId}//用来控制样式的
          >
            {
              item.data.map((i, index) =>
                <TaskItem
                  index={index}
                  data={i}
                  colId={item.id} // 所在区域的id
                  onDragStart={this.handleDragStart}
                  onDragEnd={this.handleDragEnd}
                  active={from.itemIndex === index}//用来控制样式的
                />)
            } </TaskCol>)
        } </div>
    </div>);
  }
}
/**
接收区域的js文件
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styles from './index.css';

export default class TaskCol extends Component {
  onDragEnter=() => {
  // 记录进入哪一个任务列区域了
    const { id, onDragEnter } = this.props;
    onDragEnter(id);
  }

  onDragOver=(e) => {
    // 默认地,无法将数据/元素放置到其他元素中。如果需要设置允许放置,我们必须阻止对元素的默认处理方式。这要通过调用ondragover 事件的 event.preventDefault()
    // 如果不阻止dragover的默认行为,drop就不会触发
    e.preventDefault();
  }

  render() {
    const { title, children, active } = this.props;
    return (<div className={styles.colWrapper}>
      <div className={styles.colHeader}>{title}</div>
      <div className={`${styles.listWrapper} ${active && styles.activeListWrapper}`}
      onDragEnter={this.onDragEnter}
      onDragOver={this.onDragOver}
      onDrop={this.props.onDrop}
      >
        {children}
      </div>
    </div>);
  }
}

TaskCol.propTypes = {
  title: PropTypes.string,
  id: PropTypes.string,
  onDrop: PropTypes.func,
  onDragEnter: PropTypes.func,
  active: PropTypes.bool,
  children: PropTypes.node,
};

TaskCol.defaultProps = {
  onDragLeave: () => {},
  onDragEnter: () => {},
};

/**
可拖放的元素
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styles from './index.css';


export default class TaskItem extends Component {
  onDragStart=() => {
    // 我们能知道拖拽的是哪个元素
    const { onDragStart, index, colId, data } = this.props;
    onDragStart(index, colId, data);
  }

  render() {
    const { data, active } = this.props;
    return (<div
      onDragStart={this.onDragStart}
      onDragEnd={this.props.onDragEnd}
      draggable  //可拖拽的属性
      className={`${styles.item} ${active && styles.activeItem}`}
    >
      {data.content}
    </div>);
  }
}

TaskItem.propTypes = {
  data: PropTypes.object,
  index: PropTypes.number,
  colId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), // 所在区域的标识
  onDragEnd: PropTypes.func,
  onDragStart: PropTypes.func,
  active: PropTypes.bool,
};

<!--样式 使用的是css modules-->
.colWrapper{
  border: 1px solid #d2d2d2;
  flex-grow: 1;
  width: 180px;
  height: 100%;
  margin: 0 2px;
  background: #eee;
  flex-grow: 1;
  display: flex;
  flex-direction: column;
  margin-right: 5px
}
.colHeader{
  height: 40px;
  line-height: 40px;
  background: #1DA921;
  color: #fff;
  text-align: center;
}
.listWrapper{
  overflow: auto;
  height: 100%;
}
.activeListWrapper{
  background: #00ad23;
  opacity: 0.1;
}
.item{
  border: 1px solid #1da921;
  width: 180px;
  border-radius: 5px;
  box-shadow: 0 0 5px 0 #b3b3b3;
  margin: 5px auto;
  background: #fff;
  height: 40px;
  line-height: 40px;
  padding: 0 10px;
  width: 200px;
  cursor: move
}
.activeItem {
  border-style: dashed;
}

加上排序

首先要说的是 我这个排序 是建立在每个item高度一致的基础上。如果高度不一,现在我还不知道该怎么实现。

可排序的主要思路在于,在拖动过程中,记录鼠标移到了接收区的第几个元素位置上((scrollTop+offsetY)/单个元素高度),然后原区域列表删掉元素,新区域增加元素

最新代码

import React, { Component } from 'react';
import TaskCol from './TaskCol.js';
import TaskItem from './TaskItem.js';

const STATUS_TODO = 'STATUS_TODO';
const STATUS_DOING = 'STATUS_DOING';
const STATUS_DONE = 'STATUS_DONE';

const TASK_DATA = [{
  id: STATUS_TODO,
  title: '待处理',
  data: [{
    id: 1,
    status: STATUS_TODO,
    content: '写一个react拖拽实现记录',
  },
  {
    id: 2,
    status: STATUS_TODO,
    content: '搞懂redux中间件源码',
  }, {
    id: 3,
    status: STATUS_TODO,
    content: '学习react-router',
  }, {
    id: 4,
    status: STATUS_TODO,
    content: '学习css',
  }, {
    id: 5,
    status: STATUS_TODO,
    content: '学习算法',
  },
  {
    id: 6,
    status: STATUS_TODO,
    content: '前端学习之路真的是无止境',
  },
  {
    id: 7,
    status: STATUS_TODO,
    content: '有工作 也有生活才够好',
  },
  {
    id: 8,
    status: STATUS_TODO,
    content: '有生活又有乐趣才完美',
  },
  {
    id: 9,
    status: STATUS_TODO,
    content: '加油吧 你可以的',
  },
  ],
},
{
  id: STATUS_DOING,
  title: '进行中',
  data: [],
},
{
  id: STATUS_DONE,
  title: '已完成',
  data: [],
},
];


const placeholder = <div style={{ height: 40, border: '1px dashed pink' }} />;
const defaultFrom = { // 拖方信息
  itemIndex: null, // 拖拽元素在当前所在区域列的位置
  colId: null, // 拖拽元素所在的区域列的id(唯一标识)
  item: null, // 拖拽元素全部的信息
};
const defaultTo = { // 接收方信息
  colId: null, // 接收区域列的id(唯一标识)
  toIndex: null, // 接收区域接收的元素位置   (排序新增)
};

export default class AdvancedDrag extends Component {
  constructor(props) {
    super(props);
    this.state = {
      tasks: TASK_DATA,
      from: defaultFrom,
      to: defaultTo,
    };
  }
  handleDragStart = (itemIndex, colId, item) => {
    this.setState({
      from: {
        itemIndex,
        colId,
        item,
      },
    });
  }
  handleDragEnd = () => {
    this.setState({
      from: {},
    });
  }

  /**
   * 接收方
   */
  handleDragEnter = (colId) => {
    this.setState({
      to: {
        ...this.state.to,
        colId,
      },
    });
  }

  handleDragLeave = () => {
    this.setState({
      to: defaultTo,
    });
  }
  getContainer=(ele) => {
    // 准确的找到接收方
    while (!ele.dataset.id) {
      ele = ele.parentNode;
    }
    return ele;
  }
  handleDragOver=(e) => {(排序新增)
     // (拿到当前区域的scrollTop+ 鼠标所在元素距离区域顶部的offsetTop)/元素高度 = 鼠标在第几个元素上

    // 直接e.offsetTop e.target结果是null。正确用法是从e.nativeEvent里取值
    const srcollDiv = this.getContainer(e.nativeEvent.target);
    const scrollTop = srcollDiv.scrollTop;
    // 实际上这个layerY属性的值才是距离顶部的距离。注意的是!!item元素不要加postion或者overflow属性,会影响到这layer值的准确性。虽然我也不知道为什么
    const offsetY = e.nativeEvent.layerY;
    const index = Math.floor((scrollTop + offsetY) / 40);// 40是item的高度

    if (index !== this.state.to.toIndex) {
      this.setState({ to: { ...this.state.to, toIndex: index } });
    }
  }

  handleDrop = () => {
    const tasks = this.state.tasks;
    const { from, to } = this.state;
    const { itemIndex, item } = from;

    tasks.forEach((taskItem) => {
      if (taskItem.id === from.colId && taskItem.id === to.colId) {
        taskItem.data.splice(itemIndex, 1);
        taskItem.data.splice(to.toIndex, 0, item);
      } else if (taskItem.id === from.colId) {
        taskItem.data.splice(itemIndex, 1);
      } else if (taskItem.id === to.colId) {
        taskItem.data.splice(to.toIndex, 0, item);// 这加一
      }
    });

    this.setState({
      tasks,
      from: defaultFrom,
      to: defaultTo,
    });
  }
  render() {
    const { tasks, from, to } = this.state;
    return (<div >
      <div> 有拖放 有排序 </div>
      <div style={{ display: 'flex', height: 400, width: 700 }}>
        {
          tasks.map(item => < TaskCol
            id={item.id}
            title={item.title}
            onDragEnter={this.handleDragEnter}
            onDragOver={this.handleDragOver}
            onDragLeave={this.handleDragLeave}
            onDrop={this.handleDrop}(排序新增)
            active={from.itemIndex && from.colId !== to.colId && item.id === to.colId}
          >
          //(排序新增)
            {
              item.data.map((i, index) => {
                const div = (<TaskItem
                  index={index}
                  data={i}
                  colId={item.id} // 所在区域的id
                  onDragStart={this.handleDragStart}
                  onDragEnd={this.handleDragEnd}
                  active={from.itemIndex === index && to.colId === item.id}
                />);
                // 这个占位样式加的 其实没有那么的准确。并不完全是 占位在哪,元素就会移到哪里。最准确的是元素会放在新元素上就会占据新元素的位置 越看也没看明白
                return to.colId === item.id && to.toIndex === index ? [div, placeholder] : div;
              })
            } </TaskCol>)
        } </div>
    </div>);
  }
}

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styles from './index.css';

export default class TaskCol extends Component {
  onDragEnter=() => {
  // 记录进入哪一个任务列区域了
    const { id, onDragEnter } = this.props;
    onDragEnter(id);
  }

  onDragOver=(e) => {
    // 默认地,无法将数据/元素放置到其他元素中。如果需要设置允许放置,我们必须阻止对元素的默认处理方式。这要通过调用ondragover 事件的 event.preventDefault()
    // 如果不阻止dragover的默认行为,drop就不会触发
    e.preventDefault();
    this.props.onDragOver(e);(排序新增)
  }

  render() {
    const { title, children, active, id } = this.props;
    return (<div className={styles.colWrapper}>
      <div className={styles.colHeader}>{title}</div>
      <div
        className={`${styles.listWrapper} ${active && styles.activeListWrapper}`}
        data-id={id}//(排序新增  用来精准找到 滚动元素)
        onDragEnter={this.onDragEnter}
        onDragOver={this.onDragOver}
        onDrop={this.props.onDrop}
      >
        {children}
      </div>
    </div>);
  }
}

TaskCol.propTypes = {
  title: PropTypes.string,
  id: PropTypes.string,
  onDrop: PropTypes.func,
  onDragEnter: PropTypes.func,
  active: PropTypes.bool,
  children: PropTypes.node,
  onDragOver: PropTypes.func,
};

TaskCol.defaultProps = {
  onDragLeave: () => {},
  onDragEnter: () => {},
  onDragOver: () => {},(排序新增)
};

其他文件没变化

整体上可以实现拖放排序效果了,确实还有些许瑕疵。先记录一番吧。