React DnD

前言

前面文章中我寫過 react-smooth-dnd 的拖拽匈挖,它是基于 React DnD 庫實現(xiàn),將 React DnD 重新封裝雌贱,可以直接使用它來進行排序蜓萄,排序的結(jié)果會直接返回,而 React DnD 所有的數(shù)據(jù)處理都需要自己來完成鞠眉。但是它也有它的缺點薯鼠,比如:當嵌套多層后向上、向下拖拽械蹋,如果上面或下面的容器高度不能包含拖拽容器的高度便無法放入上層或下層容器出皇;拖拽邊界會出現(xiàn)閃爍;多層嵌套拖拽時返回的順序不同哗戈,有時會先返回增加的事件恶迈,有時會先返回刪除的事件。

既然 react-smooth-dnd 是基于 React DnD 的封裝谱醇,那么使用 React Dnd 會更加靈活暇仲,我們可以使用 React DnD 來定制項目中所需要的拖拽的形式,比如:當下層容器向上移動 hover 到上層容器時將下層容器放置到上層容器中副渴,或者當下層容器向上移動到指定位置時再將下層容器放置到指定位置處等奈附。

React Dnd 可以解決 react-smooth-dnd 所帶來的問題。但是它也有自己的問題煮剧,當嵌套多層時斥滤,每一層容器上既有 useDrag 又有 useDrop 事件時會從外向內(nèi)觸發(fā),比如:多層 a, b, c勉盅,當拖動 c 時佑颇,會先觸發(fā) a 的事件,隨后是 b 的事件草娜,最后才是 c 的事件挑胸,這種情況使用官方的 monitor.isOver({ shallow: true }) 也沒有緩解,只能在每一層容器的下面增加一個容器來解決嵌套的問題宰闰。

那到底什么是 React Dnd 呢茬贵?讓我們一起來了解一下吧。

什么是 React DnD 移袍?

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

React DnD 是 React 和 Redux 的核心作者 Dan Abramov 創(chuàng)造的一組 React 高階組件,可以在保持組件分離的前提下幫助構(gòu)建復雜的拖放接口葡盗。

React DnD

React DnD 庫有 14.5k 的星螟左,可以放心使用。

React DnD 的基本概念

Backends

React DnD 抽象了后端的概念,我們可以使用 HTML5 拖拽后端胶背,也可以自定義 touch虫啥、mouse 事件模擬的后端實現(xiàn),后端主要用來抹平瀏覽器差異奄妨,處理 DOM 事件涂籽,同時把 DOM 事件轉(zhuǎn)換為 React DnD 內(nèi)部的 redux action。

Item

React DnD 基于數(shù)據(jù)驅(qū)動砸抛,當拖放發(fā)生時评雌,它用一個數(shù)據(jù)對象來描述當前的元素,比如 { cardId: 25 }直焙。

Type

類型是唯一標識應用程序中整個項目類別的字符串(或符號)景东,類似于 redux 里面的 actions types 枚舉常量。

Monitors

拖放操作都是有狀態(tài)的奔誓,React DnD 通過 Monitor 來存儲這些狀態(tài)并且提供查詢斤吐。

Connectors

Backend 關(guān)注 DOM 事件,組件關(guān)注拖放狀態(tài)厨喂,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>
}

現(xiàn)在我們來使用 React DnD 實現(xiàn)三層的拖拽

示例如下:

三層拖拽.png

一斜纪、在 index.js 文件中贫母,實現(xiàn)了組件和 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 文件中,定義了拖放的容器因块,處理數(shù)據(jù)的方法橘原。

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);
    // 根據(jù)上面算出的索引處理源數(shù)據(jù)
    const result = onUpdate(cards, key, card, dropItem);
    // 更新 state 靠柑,刷新頁面
    setCards(result);
  };

  // 列表渲染
  const onDomRender = (data, depth) => {
    // 現(xiàn)在只支持 3 層數(shù)據(jù)
    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}
          />
          {/* 當有多層數(shù)據(jù)時寨辩,遞歸渲染列表數(shù)據(jù) */}
          {item?.children?.length > 0 && onDomRender(item?.children, depth + 1)}
          {/* 空的項用來處理向此級容器內(nèi)部拖拽 */}
          {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, [])}</>;
};

數(shù)據(jù)處理使用了 immutability-helper 庫的方法吓懈。在使用 $splice 時需要注意,數(shù)據(jù)必須是數(shù)組靡狞,且有要修改的數(shù)據(jù)耻警,否則會報錯。

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 包裹,調(diào)用父組件傳過來的 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 結(jié)構(gòu)荡含。為什么不使用系統(tǒng)自帶的呢?因為定義多層容器嵌套時届垫,使用的是同層的結(jié)構(gòu)释液,所以在拖拽父容器時,子容器沒有隨著一起拖動装处,需要在這里定義一個包含子容器的父容器的結(jié)構(gòu)來覆蓋原來的結(jié)構(gòu),以使視覺看起來像是拖動了整個父容器。

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>
      {/* 拖拽后一個假的結(jié)構(gòu)蟀架,用來覆蓋未拖動的項 */}
      <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>
  );
};

現(xiàn)在我們已經(jīng)完成了一個三層的嵌套躺盛,可以愉快地進行拖拽了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末形帮,一起剝皮案震驚了整個濱河市槽惫,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌辩撑,老刑警劉巖界斜,帶你破解...
    沈念sama閱讀 216,591評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異合冀,居然都是意外死亡各薇,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,448評論 3 392
  • 文/潘曉璐 我一進店門君躺,熙熙樓的掌柜王于貴愁眉苦臉地迎上來峭判,“玉大人,你說我怎么就攤上這事棕叫×煮Γ” “怎么了?”我有些...
    開封第一講書人閱讀 162,823評論 0 353
  • 文/不壞的土叔 我叫張陵俺泣,是天一觀的道長疗认。 經(jīng)常有香客問我完残,道長,這世上最難降的妖魔是什么横漏? 我笑而不...
    開封第一講書人閱讀 58,204評論 1 292
  • 正文 為了忘掉前任谨设,我火速辦了婚禮,結(jié)果婚禮上缎浇,老公的妹妹穿的比我還像新娘扎拣。我一直安慰自己,他們只是感情好素跺,可當我...
    茶點故事閱讀 67,228評論 6 388
  • 文/花漫 我一把揭開白布鹏秋。 她就那樣靜靜地躺著,像睡著了一般亡笑。 火紅的嫁衣襯著肌膚如雪侣夷。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,190評論 1 299
  • 那天仑乌,我揣著相機與錄音百拓,去河邊找鬼。 笑死晰甚,一個胖子當著我的面吹牛衙传,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播厕九,決...
    沈念sama閱讀 40,078評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼蓖捶,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了扁远?” 一聲冷哼從身側(cè)響起俊鱼,我...
    開封第一講書人閱讀 38,923評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎畅买,沒想到半個月后并闲,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,334評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡谷羞,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,550評論 2 333
  • 正文 我和宋清朗相戀三年帝火,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片湃缎。...
    茶點故事閱讀 39,727評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡犀填,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出嗓违,到底是詐尸還是另有隱情九巡,我是刑警寧澤,帶...
    沈念sama閱讀 35,428評論 5 343
  • 正文 年R本政府宣布靠瞎,位于F島的核電站比庄,受9級特大地震影響求妹,放射性物質(zhì)發(fā)生泄漏乏盐。R本人自食惡果不足惜佳窑,卻給世界環(huán)境...
    茶點故事閱讀 41,022評論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望父能。 院中可真熱鬧神凑,春花似錦、人聲如沸何吝。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,672評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽爱榕。三九已至瓣喊,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間黔酥,已是汗流浹背藻三。 一陣腳步聲響...
    開封第一講書人閱讀 32,826評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留跪者,地道東北人棵帽。 一個月前我還...
    沈念sama閱讀 47,734評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像渣玲,于是被迫代替她去往敵國和親逗概。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,619評論 2 354