低代码平台1-react-dnd 拖拽组件

1,052 阅读4分钟

123.png

背景

"我看竞品的平台就很炫酷呀,我们也要实现一样的效果"

"两周时间够了吧,业务着急用"

又见到了熟悉的话语,身为一个技术人,对话中的是是非非都无关紧要,单从技术讨论功能的可行性

本篇文章会简单介绍一下拖拽拼装页面的基础。因为要做到从一个固定菜单拖拽组件到界面,用react-dnd的drag和drop功能能适配拖拽功能,并且能通过acceptItem指定应该接收的组件

初始化

需要安装react-dnd及其对应依赖react-dnd-html5-backend

yarn add react-dnd react-dnd-html5-backend

在根目录(如_app.tsx)中初始化dndProvider

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { MainLayout as Layout } from '@/page-components/layout/MainLayout';
require('@/styles/global.less');

function CustomApp({ Component, pageProps }: any) {

  return (
    <DndProvider backend={HTML5Backend}>
        <div id="root">
          <Layout>
            <Component {...pageProps} />
          </Layout>
        </div>
    </DndProvider>
  );
}

export default CustomApp;

react-dnd是由两个部分组成的,一个是 drag(拖)组件,一个是drop(drop)组件,这同一个组件可以同时成为drag和drop(既可拖也可以拽)。

创建页面

我们的目标是做一个可拖拽的组件菜单,能防止到页面区域,那么菜单需要是一个drag组件,这里type非常重要,比如你声明一个type为menu的drag组件,那么只有accept中有menu的drop区域可以放置这个组件


import { useDrag, useDrop } from 'react-dnd'

const Component = (compProps: ComponentProps) => {
  const { componentType } = compProps.data;


  const currentPath = `${compProps.rowIndex}-${compProps.compIndex}`;
  const [, drag] = useDrag({
    type: 'component',
    item: {
      type: 'component',
      path: currentPath,
      data: compProps.data
    },
  });

  return <div ref={drag} className="component">
    {compProps?.isMenu ? (menuComponent?.[componentType] || null) : (registeredComponent?.[componentType] || null)}
  </div>
}
    

那么页面区域里怎么做到排序呢,这就要用到两个页面区域间放置看不到的drop区域。这其中overing表示监控在拖拽的组件越过zone上空的时候的变量


const DropZone = (props: DropZoneProps) => {

  const { swapPosition, path } = props;

  const [{ overing }, drop] = useDrop({
    accept: ['component', 'menu'],
    drop(item: any) {
      // console.log('触发了drop', item, path)
      swapPosition(item, path);
    },
    collect(monitor) {
      return {
        overing: monitor.isOver()
      }
    }
  });
  return <div ref={drop}
              className={`drop-zone ${props.className} ${overing ? 'focus' : ''}`}
              style={{
                height: 20,
                background: overing ? '#027cdc' : 'red'
              }}
  ></div>
}

包裹容器,用途就是根据data来渲染页面中的components, 当然还有component前后看不见的放置区域

function RowContainer(rowContainerProps: RowContainerProps) {
  const ref = useRef(null);
  const { data, swapPosition } = rowContainerProps;

  const currentPath = `${rowContainerProps.rowIndex}` // 标注当前位置

  return <div ref={ref} className="rowContainer">
    {
      data?.map((item, index) => {
        return <div key={`comp_id_${item.id}`}>
          <DropZone className="drop-zone-horizental" swapPosition={swapPosition} path={`${currentPath}-${index}`}></DropZone>
          <Component data={item}
                     rowIndex={rowContainerProps.rowIndex}
                     compIndex={index}
          ></Component>
        </div>
      })
    }
    <DropZone swapPosition={swapPosition} className="drop-zone-horizental" path={`${currentPath}-${data?.length}`}></DropZone>
  </div>
}

那么当放置的时候怎么触发交换呢,这就要用到swapPosition方法, 如果是菜单拖拽进放置区域,那么直接添加组件,如果是页面内拖拽,那么交换他们的位置

// item 被拖拽的物体 path2: 目标路径
const swapPosition = (item: dragItem, path2: string) => {


  const newList = {...cardListTotal}
  // 如果是menu拖过来的不用删除
  if (item.path.indexOf('menu') === -1) {
    const pathForm = item.path.split('-')
    // 删除原来的item
    newList[parseInt(pathForm[0])].splice(parseInt(pathForm[1]), 1)
  }

  const pathTo = path2.split('-')
  newList[parseInt(pathTo[0])].splice(parseInt(pathTo[1]), 0, item.data)

  setCardListTotal(newList)
}

整体效果

image.png

全部代码

import React, {useEffect, useRef, useState, useContext, useCallback} from 'react';

import { useDrag, useDrop } from 'react-dnd'

/**
 * 注册组件区域
 */

interface LayoutItem {
  type: string;
  id: string;
  componentType: string
}

// 拖拽时候传的数据
interface dragItem {
  type: string,
  path: string,
  data: LayoutItem
}

const style= {
  borderStyle:'solid',
  background: 'skyblue',
  width: 200,
  lineHeight: '60px',
  padding: '0 20px',
  border: '1px solid #000',
  margin: '0 10px',
  cursor: 'move',
}

const registeredComponent: Record<string, any> = {
  component1: <div style={style}>component1</div>,
  component2: <div style={style}>component2</div>,
  component3: <div style={style}>component3</div>
}

const menuComponent: Record<string, any> = {
  component1: <div style={style}>菜单1</div>,
  component2: <div style={style}>菜单2</div>,
  component3: <div style={style}>菜单3</div>
}

interface ComponentProps {
  data: LayoutItem,
  rowIndex: number | string; // 行index
  compIndex: number; // 组件index
  isMenu?: boolean; // 是否是菜单样式
}
const Component = (compProps: ComponentProps) => {
  const { componentType } = compProps.data;


  const currentPath = `${compProps.rowIndex}-${compProps.compIndex}`;
  const [, drag] = useDrag({
    type: 'component',
    item: {
      type: 'component',
      path: currentPath,
      data: compProps.data
    },
  });

  return <div ref={drag} className="component">
    {compProps?.isMenu ? (menuComponent?.[componentType] || null) : (registeredComponent?.[componentType] || null)}
  </div>
}

/**
 * 注册组件区域结束
 */


/**
 * 组件容器区域
 */

interface RowContainerProps {
  data: LayoutItem[];
  rowIndex: number;
  swapPosition: (item: dragItem, path2: string) => void
}

function RowContainer(rowContainerProps: RowContainerProps) {
  const ref = useRef(null);
  const { data, swapPosition } = rowContainerProps;

  const currentPath = `${rowContainerProps.rowIndex}` // 标注当前位置

  return <div ref={ref} className="rowContainer">
    {
      data?.map((item, index) => {
        return <div key={`comp_id_${item.id}`}>
          <DropZone className="drop-zone-horizental" swapPosition={swapPosition} path={`${currentPath}-${index}`}></DropZone>
          <Component data={item}
                     rowIndex={rowContainerProps.rowIndex}
                     compIndex={index}
          ></Component>
        </div>
      })
    }
    <DropZone swapPosition={swapPosition} className="drop-zone-horizental" path={`${currentPath}-${data?.length}`}></DropZone>
  </div>
}

/**
 * 组件容器区域结束
 */

/**
 * 交换区域
 */
interface DropZoneProps {
  className: string;
  path: string;
  swapPosition: (item: dragItem, path: string,) => void
}

const DropZone = (props: DropZoneProps) => {

  const { swapPosition, path } = props;

  const [{ overing }, drop] = useDrop({
    accept: ['component', 'menu'],
    drop(item: any) {
      // console.log('触发了drop', item, path)
      swapPosition(item, path);
    },
    collect(monitor) {
      return {
        overing: monitor.isOver()
      }
    }
  });
  return <div ref={drop}
              className={`drop-zone ${props.className} ${overing ? 'focus' : ''}`}
              style={{
                height: 20,
                background: overing ? '#027cdc' : 'red'
              }}
  ></div>
}

/**
 * 交换区域结束
 */


const MissionList = () => {

  const [cardListTotal, setCardListTotal] = useState<LayoutItem[][]>([
    // 交集池
    [
      {
        id: '0',
        type: 'component',
        componentType: 'component1'
      },
      {
        id: '1',
        type: 'component',
        componentType: 'component2'
      },
    ],
      // 并集池
    [
      {
        id: '5',
        type: 'component',
        componentType: 'component3'
      },
      {
        id: '6',
        type: 'component',
        componentType: 'component1'
      },
    ],
      // 排除池
    []
  ]
  )

  const [menuList, setMenuList] = useState([{
    id: '8',
    type: 'component',
    componentType: 'component3'
  }])


  // item 被拖拽的物体 path2: 目标路径
  const swapPosition = (item: dragItem, path2: string) => {


    const newList = {...cardListTotal}
    // 如果是menu拖过来的不用删除
    if (item.path.indexOf('menu') === -1) {
      const pathForm = item.path.split('-')
      // 删除原来的item
      newList[parseInt(pathForm[0])].splice(parseInt(pathForm[1]), 1)
    }

    const pathTo = path2.split('-')
    newList[parseInt(pathTo[0])].splice(parseInt(pathTo[1]), 0, item.data)

    setCardListTotal(newList)
  }

  return (
    <div style={{display: 'flex'}}>
      <div style={{border: '1px solid #ccc', minWidth: 200}}>
        <div>左侧菜单</div>
        <Component data={menuList[0]}
                   rowIndex='menu'
                   compIndex={0}
        ></Component>
      </div>

      <div style={{border: '1px solid #ccc', minWidth: 200}}>
        <div>交集区</div>
        <RowContainer swapPosition={swapPosition} rowIndex={0} data={cardListTotal[0]}></RowContainer>
      </div>

      <div style={{border: '1px solid #ccc', minWidth: 200}}>
        <div>并集区</div>
        <RowContainer swapPosition={swapPosition} rowIndex={1} data={cardListTotal[1]}></RowContainer>
      </div>

      <div style={{border: '1px solid #ccc', minWidth: 200}}>
        <div>排除区</div>
        <RowContainer swapPosition={swapPosition} rowIndex={2} data={cardListTotal[2]}></RowContainer>
      </div>
    </div>
  );
};

export default MissionList;