React DnD

前言

前面文章中我写过 react-smooth-dnd 的拖拽,它是基于 React DnD 库实现,将 React DnD 重新封装,可以直接使用它来进行排序,排序的结果会直接返回,而 React DnD 所有的数据处理都需要自己来完成。但是它也有它的缺点,比如:当嵌套多层后向上、向下拖拽,如果上面或下面的容器高度不能包含拖拽容器的高度便无法放入上层或下层容器;拖拽边界会出现闪烁;多层嵌套拖拽时返回的顺序不同,有时会先返回增加的事件,有时会先返回删除的事件。

既然 react-smooth-dnd 是基于 React DnD 的封装,那么使用 React Dnd 会更加灵活,我们可以使用 React DnD 来定制项目中所需要的拖拽的形式,比如:当下层容器向上移动 hover 到上层容器时将下层容器放置到上层容器中,或者当下层容器向上移动到指定位置时再将下层容器放置到指定位置处等。

React Dnd 可以解决 react-smooth-dnd 所带来的问题。但是它也有自己的问题,当嵌套多层时,每一层容器上既有 useDrag 又有 useDrop 事件时会从外向内触发,比如:多层 a, b, c,当拖动 c 时,会先触发 a 的事件,随后是 b 的事件,最后才是 c 的事件,这种情况使用官方的 monitor.isOver({ shallow: true }) 也没有缓解,只能在每一层容器的下面增加一个容器来解决嵌套的问题。

那到底什么是 React Dnd 呢?让我们一起来了解一下吧。

什么是 React DnD ?

React DnD 的英文是 Drag and Drop for React。

React DnD 是 React 和 Redux 的核心作者 Dan Abramov 创造的一组 React 高阶组件,可以在保持组件分离的前提下帮助构建复杂的拖放接口。

React DnD

React DnD 库有 14.5k 的星,可以放心使用。

React DnD 的基本概念

Backends

React DnD 抽象了后端的概念,我们可以使用 HTML5 拖拽后端,也可以自定义 touch、mouse 事件模拟的后端实现,后端主要用来抹平浏览器差异,处理 DOM 事件,同时把 DOM 事件转换为 React DnD 内部的 redux action。

Item

React DnD 基于数据驱动,当拖放发生时,它用一个数据对象来描述当前的元素,比如 { cardId: 25 }。

Type

类型是唯一标识应用程序中整个项目类别的字符串(或符号),类似于 redux 里面的 actions types 枚举常量。

Monitors

拖放操作都是有状态的,React DnD 通过 Monitor 来存储这些状态并且提供查询。

Connectors

Backend 关注 DOM 事件,组件关注拖放状态,connector 可以连接组件和 Backend ,可以让 Backend 获取到 DOM。

useDrag

用于将当前组件用作拖动源的钩子。

import { useDrag } from 'react-dnd'

function DraggableComponent(props) {
  const [collectedProps, drag] = useDrag({
    item: { id, type }
  })
  return <div ref={drag}>...</div>
}

useDrop

使用当前组件作为放置目标的钩子。


function myDropTarget(props) {
  const [collectedProps, drop] = useDrop({
    accept
  })

  return <div ref={drop}>Drop Target</div>
}

useDragLayer

用于将当前组件用作拖动层的钩子。

import { useDragLayer } from 'react-dnd'

function DragLayerComponent(props) {
  const collectedProps = useDragLayer(spec)
  return <div>...</div>
}

现在我们来使用 React DnD 实现三层的拖拽

示例如下:

三层拖拽.png

一、在 index.js 文件中,实现了组件和 Backend 的连接,可以让 Backend 获取到 DOM。

import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

import Container from './container';
import DragLayer from './dragLayer';

export default () => {
  return (
    <div>
      <DndProvider backend={HTML5Backend}>
        {/* 拖拽容器 */}
        <Container />
        {/* 正在拖拽的项 */}
        <DragLayer />
      </DndProvider>
    </div>
  );
};

二、在 container.js 文件中,定义了拖放的容器,处理数据的方法。

import React, { useState } from 'react';

import classnames from 'classnames';

import Card from './card';
import { ITEMS, DATA_EMPTY } from './constants';
import { onGetIndex, onCalcPos, onUpdate } from './util';

import styles from './index.less';

export default () => {
  const [cards, setCards] = useState(ITEMS);

  // 找到拖拽的项,返回选中项和索引
  const findCard = fieldName => {
    const { card, index } = onGetIndex(fieldName, cards);
    return {
      card,
      index,
    };
  };

  /**
   * 移动
   * @param {string} fieldName 拖拽的项
   * @param {array} atIndex 释放的项的索引
   */
  const moveCard = (fieldName, atIndex, dropItem) => {
    // 正在拖拽的项
    const { card, index } = findCard(fieldName);
    // 要放置的项的信息
    const { lastFieldName, fieldName: droppedFieldName } = dropItem;
    let placeIndex = atIndex;
    let isAdd = false;
    // 要放置的是空的项,增加处理逻辑
    if (droppedFieldName?.includes(DATA_EMPTY)) {
      const { index: dropIndex } = findCard(lastFieldName);
      placeIndex = dropIndex;
      isAdd = true;
    }
    if (placeIndex === index || index?.length === 0) {
      return;
    }
    // 计算要放置和移除的索引
    const key = onCalcPos(placeIndex, index, dropItem, cards, card, isAdd);
    // 根据上面算出的索引处理源数据
    const result = onUpdate(cards, key, card, dropItem);
    // 更新 state ,刷新页面
    setCards(result);
  };

  // 列表渲染
  const onDomRender = (data, depth) => {
    // 现在只支持 3 层数据
    if (depth > 3) {
      return;
    }
    return data?.map((item, index) => {
      return (
        <div
          className={classnames({
            [styles.container]: depth === 1,
            [styles.group]: depth !== 1,
          })}
          key={`${item?.fieldName}-r`}
        >
          <Card
            key={`${item?.fieldName}-c`}
            fieldName={item?.fieldName}
            label={item?.label}
            depth={depth}
            noBorder={item?.children?.length === 0 && depth !== 3}
            isLast={
              item?.children?.length === 0 &&
              index === data?.length - 1 &&
              depth !== 1
            }
            hasChildren={item?.children?.length > 0}
            moveCard={moveCard}
            findCard={findCard}
          />
          {/* 当有多层数据时,递归渲染列表数据 */}
          {item?.children?.length > 0 && onDomRender(item?.children, depth + 1)}
          {/* 空的项用来处理向此级容器内部拖拽 */}
          {item?.children?.length === 0 && (
            <Card
              key={`${DATA_EMPTY}-${item?.fieldName}`}
              fieldName={`${item?.fieldName}-${DATA_EMPTY}`}
              label={`${item?.fieldName}-${DATA_EMPTY}`}
              depth={depth}
              lastFieldName={item?.fieldName}
              moveCard={moveCard}
              findCard={findCard}
            />
          )}
        </div>
      );
    });
  };

  return <>{onDomRender(cards, 1, [])}</>;
};

数据处理使用了 immutability-helper 库的方法。在使用 $splice 时需要注意,数据必须是数组,且有要修改的数据,否则会报错。

import update from 'immutability-helper';
export const onUpdate = (cards, key, card) => {
  const { atGroup, atSalary, atField, group, salary, field } = key;
  const add = update(
    cards,
    typeof atSalary !== 'undefined'
      ? {
          [atGroup]: {
            children:
              typeof atField !== 'undefined'
                ? {
                    [atSalary]: {
                      children: {
                        $splice: [[atField, 0, card]],
                      },
                    },
                  }
                : {
                    $splice: [[atSalary, 0, card]],
                  },
          },
        }
      : typeof atGroup !== 'undefined'
      ? { $splice: [[atGroup, 0, card]] }
      : {},
  );
  const result = update(
    add,
    typeof salary !== 'undefined'
      ? {
          [group]: {
            children:
              typeof field !== 'undefined'
                ? {
                    [salary]: {
                      children: {
                        $splice: [[field, 1]],
                      },
                    },
                  }
                : {
                    $splice: [[salary, 1]],
                  },
          },
        }
      : typeof group !== 'undefined'
      ? { $splice: [[group, 1]] }
      : {},
  );
  return result;
};

三、在 card.js 文件中,定义了拖放项,使用 useDrag 和 useDrop 包裹,调用父组件传过来的 findCard,moveCard 方法来处理拖拽。

import React, { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import classnames from 'classnames';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { ItemTypes, DATA_EMPTY } from './constants';
import styles from './index.less';

export default ({
  fieldName,
  label,
  depth,
  moveCard,
  findCard,
  noBorder,
  lastFieldName,
  isLast,
  hasChildren,
}) => {
  const { index: originalIndex, card } = findCard(fieldName);
  let lastDraggedFieldName = '';
  const [{ isDragging }, drag, preview] = useDrag({
    item: {
      type: ItemTypes.CARD,
      fieldName,
      label,
      card,
      depth,
      originalIndex,
      isLast,
      hasChildren,
    },
    collect: monitor => ({
      isDragging: monitor.isDragging(),
    }),
  });

  useEffect(() => {
    // 使用自定义的拖拽 DOM
    preview(getEmptyImage(), { captureDraggingState: true });
  }, []);

  const [, drop] = useDrop({
    accept: ItemTypes.CARD,
    hover({ fieldName: draggedFieldName, depth: draggedDepth }, monitor) {
      const { y: offsetY } = monitor.getDifferenceFromInitialOffset();
      if (!fieldName || !draggedFieldName) {
        return;
      }
      if (
        draggedFieldName !== fieldName &&
        draggedFieldName !== lastDraggedFieldName &&
        !fieldName?.includes(draggedFieldName)
      ) {
        lastDraggedFieldName = draggedFieldName;
        const { index: overIndex } = findCard(fieldName);
        moveCard(draggedFieldName, overIndex, {
          fieldName,
          depth,
          draggedDepth,
          draggedFieldName,
          offsetY,
          lastFieldName,
        });
      }
    },
  });

  const opacity = isDragging ? 0 : 1;
  const paddingLeft = depth ? `${depth}rem` : '1rem';

  return (
    <div
      key={fieldName}
      ref={node => drag(drop(node))}
      className={classnames(styles.element, {
        [styles.empty]: fieldName?.includes(DATA_EMPTY),
        [styles.noBorder]:
          noBorder || (!fieldName?.includes(DATA_EMPTY) && depth === 3),
        [styles.eleBox]: hasChildren,
      })}
      style={{ opacity, paddingLeft }}
    >
      {label}
    </div>
  );
};

四、在 dragLayer.js 文件中, 定义了拖拽时显示的 DOM 结构。为什么不使用系统自带的呢?因为定义多层容器嵌套时,使用的是同层的结构,所以在拖拽父容器时,子容器没有随着一起拖动,需要在这里定义一个包含子容器的父容器的结构来覆盖原来的结构,以使视觉看起来像是拖动了整个父容器。

import React from 'react';
import { useDragLayer } from 'react-dnd';
import classnames from 'classnames';

import CardLayer from './cardLayer';
import { getFixedStyles, getItemStyles } from './layerUtil';
import { DATA_EMPTY } from './constants';

import styles from './index.less';

export default () => {
  const {
    isDragging,
    dragItem,
    initialOffset,
    currentOffset,
    differenceOffset,
  } = useDragLayer(monitor => ({
    dragItem: monitor.getItem(),
    initialOffset: monitor.getInitialSourceClientOffset(),
    currentOffset: monitor.getSourceClientOffset(),
    isDragging: monitor.isDragging(),
    differenceOffset: monitor.getDifferenceFromInitialOffset(),
  }));
  if (!isDragging) {
    return null;
  }
  // 列表渲染
  const onDomRender = (data, depth, show) => {
    if (depth > 3) {
      return;
    }
    return data?.map(item => {
      return (
        <div
          className={classnames({
            [styles.container]: depth === 1,
            [styles.group]: depth !== 1,
          })}
          key={`${item?.fieldName}-${show}-layer-c`}
        >
          <CardLayer
            key={`${item?.fieldName}-${show}-layer-c`}
            label={item?.label}
            show={show}
            noBorder={item?.children?.length === 0 && depth !== 3}
            depth={depth}
            isLast={item?.isLast}
            hasChildren={item?.hasChildren}
          />
          {item?.children?.length > 0 &&
            onDomRender(item?.children, depth + 1, show)}
          {item?.children?.length === 0 && (
            <CardLayer
              key={`${item?.fieldName}-${show}-layer-${DATA_EMPTY}`}
              label={DATA_EMPTY}
              show={show}
              depth={depth}
              isLast={item?.isLast}
              hasChildren={item?.hasChildren}
            />
          )}
        </div>
      );
    });
  };

  return (
    <div>
      {/* 拖拽后一个假的结构,用来覆盖未拖动的项 */}
      <div
        className={classnames(styles.mask, styles.maskLayer, {
          [styles.offsetToBottom]: differenceOffset?.y > 0,
        })}
      >
        <div
          className={classnames(styles.maskContainer, {
            [styles.maskFrame]: dragItem?.depth !== 1 && !dragItem?.isLast,
            [styles.dragEle]: dragItem?.depth !== 1 && dragItem?.isLast,
          })}
          style={getFixedStyles(
            initialOffset,
            currentOffset,
            differenceOffset,
            dragItem?.depth,
            dragItem?.isLast,
          )}
        >
          <CardLayer
            key={`${dragItem?.fieldName}-mask-c`}
            label={dragItem?.label}
            show={false}
            isLast={dragItem?.isLast}
            hasChildren={dragItem?.hasChildren}
          />
          {dragItem?.card?.children?.length > 0 &&
            onDomRender(dragItem?.card?.children, dragItem?.depth + 1, false)}
          {dragItem?.card?.children?.length === 0 && (
            <CardLayer
              key={`${dragItem?.fieldName}-mask-${DATA_EMPTY}`}
              label={DATA_EMPTY}
              show={false}
              isLast={dragItem?.isLast}
              hasChildren={dragItem?.hasChildren}
            />
          )}
        </div>
      </div>

      {/* 正在拖拽的项 */}
      <div className={classnames(styles.mask, styles.dragLayer, {})}>
        <div
          className={classnames(styles.dragEle, {
            [styles.container]: dragItem?.depth === 1,
            [styles.group]: dragItem?.depth !== 1,
          })}
          style={getItemStyles(currentOffset)}
        >
          <CardLayer
            key={`${dragItem?.fieldName}-layer-c`}
            label={dragItem?.label}
            show={true}
            noBorder={
              dragItem?.card?.children?.length === 0 && dragItem?.depth !== 3
            }
            depth={dragItem?.depth}
            isLast={dragItem?.isLast}
            hasChildren={dragItem?.hasChildren}
          />
          {dragItem?.card?.children?.length > 0 &&
            onDomRender(dragItem?.card?.children, dragItem?.depth + 1, true)}
          {dragItem?.card?.children?.length === 0 && (
            <CardLayer
              key={`${dragItem?.fieldName}-layer-${DATA_EMPTY}`}
              label={DATA_EMPTY}
              show={true}
              depth={dragItem?.depth}
              isLast={dragItem?.isLast}
              hasChildren={dragItem?.hasChildren}
            />
          )}
        </div>
      </div>
    </div>
  );
};

五、在 cardLayer.js 文件中,定义了拖拽时显示的 DOM 项。

import React from 'react';
import classnames from 'classnames';

import { DATA_EMPTY } from './constants';
import styles from './index.less';

export default ({
  label,
  show,
  depth,
  noBorder,
  hasChildren,
}) => {
  const color = show ? 'black' : 'white';
  const paddingLeft = depth ? `${depth}rem` : '1rem';
  return (
    <div
      className={classnames(styles.element, {
        [styles.empty]: label === DATA_EMPTY,
        [styles.noBorder]:
          !show ||
          (show && noBorder) ||
          (show && label !== DATA_EMPTY && depth === 3),
        [styles.eleBox]: hasChildren,
      })}
      style={{ color, paddingLeft }}
    >
      {label}
    </div>
  );
};

现在我们已经完成了一个三层的嵌套,可以愉快地进行拖拽了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342