Flip动画+拖拽API实现一个拖拽排序公共组件

文末有本案例的完整代码,可直接复制,如想查看更多案例请访问: 源码地址github,

效果图

组件功能分析

在使用拖拽组件DragBox时,有三点要求:

  1. 希望通过render props的形式渲染自定义拖拽子元素,同时传递数据给子元素
  2. 在拖拽完毕后可以获取到排序后的数据
  3. 使用动画,使用户拖拽元素时更流畅

DragBox组件属性分析:

  1. data: 需要一个data属性,用来接收用户传递的数据,根据数据循环渲染子元素
  2. itemKey: 列表渲染时的key,
  3. children: 子元素,接收三个参数: item,index,data返回一个jsx作为子元素进行渲染

DragBox事件分析:

  1. onDragEnd事件: 需要在拖拽结束后,通过参数将排序后的数据传递
伪代码

根据上面的分析,可以对应下面的伪代码:

ini 复制代码
interface Props<T> {
  data: T[];
  onDragEnd: (data: T[]) => void;
  children: (item: T, index: number, data: T[]) => React.ReactNode;
  itemKey?: keyof T;
}  
​
<DragBox<dataType>
    data={data}
    onDragEnd={onDragEnd}
    itemKey="index"
  >
    {item => <div style={{ height: '50px', lineHeight: '50px', textAlign: 'center' }}>{item.name}</div>}
  </DragBox>

实现思路

根据功能分析,该组件具有两个难点:

  1. 拖拽排序与数据排序如何关联起来
  2. 动画如何实现

问题1解决思路

拖拽子元素时,根据onDragEnter事件获取拖拽元素sourceNode所进入的目标元素targetNode,然后根据通过insertBefore来动态调整两个元素的位置,从而达到视觉上的拖拽排序效果

上一步中,用户的拖拽会导致DOM结构发生变换,实际上此时的DOM结构就对应排序后的数据顺序,因此,可以通过data-属性将子元素的索引挂载到子元素的dom对象上,拖拽结束后,获取所有的子元素,并提取index对原数据进行排序

问题2解决思路

通过上面的拖拽排序的实现思路,我们知道这个过程涉及到DOM 结构的变化,因此单纯使用css属性无法完成拖拽动画.这里要使用一种动画实现思维Flip就可以实现了,下面简单介绍一下这个动画实现思想:

一句话总结获取元素初始状态,获取元素结束状态,计算状态插值,使用animate API通过tranform实现该过程的变化 ,FlipFirst(初始),last(结束),invert(反转),paly(运行)四个首字母的缩写,具体如何引用,将会在下文中详细描述

排序逻辑实现

组件结构如下,将使用事件委托的形式在父元素处理所有事件:

ini 复制代码
  return (
    <div
      onDragStart={_onDragStart}
      onDragEnter={_onDragEnter}
      onDragEnd={_onDragEnd}
      onDragOver={_onDragOver}
      className={styles.wrap}
      ref={wrapRef} // 获取父元素
    >
      {data &&
        data.map((item, index) => {
          return (
            <div
              id="drag-item" // 唯一id,用来标识该元素是拖拽元素
              data-index={index} // 通过data-属性挂载元素初始索引,用于数据排序
              draggable // 添加draggable属性,使元素可以拖拽
              key={itemKey && item[itemKey] !== undefined ? String(item[itemKey]) : index}
            >
              {children(item, index, data)}
            </div>
          );
        })}
    </div>
  );

根据上面的结构,梳理实现思路就简单多了:

  1. _onDragStart: 获取到拖拽元素sourceNode

    ini 复制代码
      const _onDragStart = (e: React.DragEvent<HTMLDivElement>) => {
        e.persist();
        sourceNode = e.target as HTMLDivElement; // sourceNode为全局变量
        setTimeout(() => {
          sourceNode!.classList.add(styles.moving); // 设置成异步,否则拖拽元素的样式有问题
        }, 0);
      };
  2. _onDragEnter: 获取到目标元素targetNode,然后根据元素的索引判断元素位置,如果sourceNode位置高于targetNode那么是向下拖拽,sourceNode应该插入targetNode.nextElementSibling之前,反之sourceNode应该插入在targetNode之前

    ini 复制代码
      const _onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        const targetNode = (e.target as HTMLElement).closest('#drag-item') as HTMLDivElement; // 正确获取到子元素
        if (targetNode && targetNode !== sourceNode) {
          const dragItems = [...wrapRef.current!.children] as HTMLDivElement[];
          const sourceIndex = dragItems.indexOf(sourceNode!);
          const targetIndex = dragItems.indexOf(targetNode);
          if (sourceIndex > targetIndex) {
            // 向上
            wrapRef.current!.insertBefore(sourceNode!, targetNode);
          } else {
            // 向下
            wrapRef.current!.insertBefore(sourceNode!, targetNode.nextElementSibling);
          }
        }
      };
  3. _onDragEnd: 拖拽结束,根据当前子元素dataset获取到索引的排列顺序,根据这个排列顺序重排源数据,并调用onDragEnd将排列后的数据返回

    ini 复制代码
    const _onDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
        (e.target as HTMLDivElement).classList.remove(styles.moving);
        const dragItems = [...wrapRef.current!.children] as HTMLDivElement[];
        const currentOrder = dragItems.map(item => Number(item.dataset.index)); // 获取当前排序
        const orderData = currentOrder.map(item => data[item]); // 重新排序
        onDragEnd(orderData);
    };
  4. _onDragOver:阻止默认行为,否则拖拽时鼠标会显示禁止

    ini 复制代码
    const _onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
    };

动画逻辑实现

通过面向对象的方式,统一抽象并封装单个DOM元素的Flip动画行为,代码中有详细注释:

kotlin 复制代码
class FlipDOM {
  private dom: HTMLElement; 
  private duration: number; // 单位秒
  private firstPos: { top: number; left: number };
  private lastPos: { top: number; left: number };
  private invert: { top: number; left: number };
  private playing: boolean;
​
  constructor(dom: HTMLElement, duration: number) {
    this.dom = dom; // dom元素
    this.duration = duration * 1000;
    this.firstPos = { top: 0, left: 0 }; // 初始状态
    this.lastPos = { top: 0, left: 0 }; // 结束状态
    this.invert = { top: 0, left: 0 }; // 状态差值
    this.playing = false; // 是否正在进行动画
    this.getFirstPos();// 实例化时获取初始状态
  }
​
  // First
  getFirstPos() {
    const { top, left } = this.dom.getBoundingClientRect();
    this.firstPos = { top, left };
  }
  // Last
  getLastPos() {
    const { top, left } = this.dom.getBoundingClientRect();
    this.lastPos = { top, left };
  }
  // invert
  getInvert() {
    this.invert = {
      top: this.firstPos.top - this.lastPos.top,
      left: this.firstPos.left - this.lastPos.left
    };
  }
  // play
  play() {
    this.getLastPos(); // 获取结束状态
    this.getInvert(); // 计算状态差值
    if (this.playing || (!this.invert.top && !this.invert.left)) { // 如果正在运行动画,或者状态没变化,则不执行动画
      return;
    }
    this.playing = true;
    this.firstPos = this.lastPos;
    this.dom.getAnimations().forEach(animation => animation.cancel());
    this.dom.animate(
      [
        {
          transformOrigin: 'top left',
          transform: `translate(${this.invert.left}px,${this.invert.top}px)`
        },
        {
          transformOrigin: 'top left',
          transform: `none`
        }
      ],
      { duration: this.duration }
    );
      // 动画执行完毕再打开开关
    setTimeout(() => {
      this.playing = false;
    }, this.duration);
  }
}
export { FlipDOM };

针对拖拽组件再封装一个类,用来执行大批量dom的动画:

typescript 复制代码
class FlipDOMs {
  private filpDOMInstances: FlipDOM[];
​
  constructor(doms: HTMLElement[], duration: number = 0.2) {
    this.filpDOMInstances = doms.map(item => new FlipDOM(item, duration)); // 将所有DOM实例化
  }
  play() {
    this.filpDOMInstances.forEach(item => item.play()); // 调用所有示例的play方法
  }
}
​
export default FlipDOMs;

_onDragStart中,记录初始状态:

ini 复制代码
const _onDragStart = (e: React.DragEvent<HTMLDivElement>) => {
      ...
    flipDOMS = new FlipDOMs([...wrapRef.current!.children] as HTMLElement[], 0.2); // flipDOMS为全局变量
      ...
};

_onDragEnter中,执行动画:

ini 复制代码
​
  const _onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
      ...
    if (targetNode && targetNode !== sourceNode) {
        ...
      flipDOMS!.play();
    }
  };

至此一个拖拽排序组件就已经封装好了,下面是完整代码: DragBox.tsx

ini 复制代码
import { useRef } from 'react';
import styles from './index.module.less';
import FlipDOMs from './flip.ts';
interface Props<T> {
  data: T[];
  onDragEnd: (data: T[]) => void;
  children: (item: T, index: number, data: T[]) => React.ReactNode;
  itemKey?: keyof T;
}
​
const DragBox = <T,>({ data, onDragEnd, children, itemKey }: Props<T>) => {
  const wrapRef = useRef<HTMLDivElement>(null);
  let sourceNode: HTMLDivElement | null = null;
  let flipDOMS: FlipDOMs | null = null;
​
  const _onDragStart = (e: React.DragEvent<HTMLDivElement>) => {
    e.persist();
    sourceNode = e.target as HTMLDivElement;
    flipDOMS = new FlipDOMs([...wrapRef.current!.children] as HTMLElement[], 0.2);
    setTimeout(() => {
      sourceNode!.classList.add(styles.moving);
    }, 0);
  };
  const _onDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
    (e.target as HTMLDivElement).classList.remove(styles.moving);
    const dragItems = [...wrapRef.current!.children] as HTMLDivElement[];
    const currentOrder = dragItems.map(item => Number(item.dataset.index));
    const orderData = currentOrder.map(item => data[item]);
    onDragEnd(orderData);
  };
​
  const _onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const targetNode = (e.target as HTMLElement).closest('#drag-item') as HTMLDivElement;
    if (targetNode && targetNode !== sourceNode) {
      const dragItems = [...wrapRef.current!.children] as HTMLDivElement[];
      const sourceIndex = dragItems.indexOf(sourceNode!);
      const targetIndex = dragItems.indexOf(targetNode);
      if (sourceIndex > targetIndex) {
        // 向上
        wrapRef.current!.insertBefore(sourceNode!, targetNode);
      } else {
        // 向下
        wrapRef.current!.insertBefore(sourceNode!, targetNode.nextElementSibling);
      }
      flipDOMS!.play();
    }
  };
  const _onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };
​
  return (
    <div
      onDragStart={_onDragStart}
      onDragEnter={_onDragEnter}
      onDragEnd={_onDragEnd}
      onDragOver={_onDragOver}
      className={styles.wrap}
      ref={wrapRef}
    >
      {data &&
        data.map((item, index) => {
          return (
            <div
              id="drag-item"
              data-index={index}
              className={[
                styles['drag-item'],
                index % 2 === 0 ? styles['drag-item-odd'] : styles['drag-item-even']
              ].join(' ')}
              draggable
              key={itemKey && item[itemKey] !== undefined ? String(item[itemKey]) : index}
            >
              {children(item, index, data)}
            </div>
          );
        })}
    </div>
  );
};
export default DragBox;

flip.ts

kotlin 复制代码
class FlipDOM {
  private dom: HTMLElement;
  private duration: number; // 单位秒
  private firstPos: { top: number; left: number };
  private lastPos: { top: number; left: number };
  private invert: { top: number; left: number };
  private playing: boolean;
​
  constructor(dom: HTMLElement, duration: number) {
    this.dom = dom;
    this.duration = duration * 1000;
    this.firstPos = { top: 0, left: 0 };
    this.lastPos = { top: 0, left: 0 };
    this.invert = { top: 0, left: 0 };
    this.playing = false;
    this.getFirstPos();
  }
​
  // First
  getFirstPos() {
    const { top, left } = this.dom.getBoundingClientRect();
    this.firstPos = { top, left };
  }
  // Last
  getLastPos() {
    const { top, left } = this.dom.getBoundingClientRect();
    this.lastPos = { top, left };
  }
  // invert
  getInvert() {
    this.invert = {
      top: this.firstPos.top - this.lastPos.top,
      left: this.firstPos.left - this.lastPos.left
    };
  }
  // play
  play() {
    this.getLastPos();
    this.getInvert();
    if (this.playing || (!this.invert.top && !this.invert.left)) {
      return;
    }
    this.playing = true;
    this.firstPos = this.lastPos;
    this.dom.getAnimations().forEach(animation => animation.cancel());
    this.dom.animate(
      [
        {
          transformOrigin: 'top left',
          transform: `translate(${this.invert.left}px,${this.invert.top}px)`
        },
        {
          transformOrigin: 'top left',
          transform: `none`
        }
      ],
      { duration: this.duration }
    );
    setTimeout(() => {
      this.playing = false;
    }, this.duration);
  }
}
​
class FlipDOMs {
  private filpDOMInstances: FlipDOM[];
  constructor(doms: HTMLElement[], duration: number = 0.2) {
    this.filpDOMInstances = doms.map(item => new FlipDOM(item, duration));
  }
  play() {
    this.filpDOMInstances.forEach(item => item.play());
  }
}
​
export default FlipDOMs;
export { FlipDOM };

index.less

css 复制代码
.wrap {
  border: 1px solid #f0f0f0;
}
​
.drag-item {
  margin: 20px;
  outline: 1px solid #f0f0f0;
  cursor: move;
}
​
.drag-item-odd {
  background-color: #fff;
}
​
.drag-item-even {
  background-color: #f8f8f8;
}
​
.moving {
  color: transparent;
  background: transparent;
  outline: 1px dashed #ccc;
}
相关推荐
前端进阶者1 分钟前
天地图编辑支持删除编辑点
前端·javascript
江号软件分享10 分钟前
无接触服务的关键:二维码生成识别技术详解
前端
江号软件分享10 分钟前
如何利用取色器实现跨平台色彩一致性
前端
灰海14 分钟前
封装WebSocket
前端·网络·websocket·网络协议·vue
前端小巷子25 分钟前
深入理解TCP协议
前端·javascript·面试
万少26 分钟前
鸿蒙外包的十大生存法则
前端·后端·面试
顽疲40 分钟前
从零用java实现 小红书 springboot vue uniapp(13)模仿抖音视频切换
java·vue.js·spring boot
江号软件分享1 小时前
有效保障隐私,如何安全地擦除电脑上的敏感数据
前端
web守墓人2 小时前
【前端】ikun-markdown: 纯js实现markdown到富文本html的转换库
前端·javascript·html
Savior`L2 小时前
CSS知识复习5
前端·css