React Hooks实现自定义表格列

2,613 阅读4分钟

一、前言

目标

ant designTable组件基础之上利用react-dnd实现表格列的拖拽排序、并自定义列的显示隐藏。

Tips
  • 请先了解ant design组件库中Table组件的用法,本文不再展开
  • 本文不展开介绍react-dnd的基础知识,不太了解它的同学可以先参考文末的文章学习

当前版本

  • react: 16.14.0
  • react-dom: 16.14.0
  • antd: 3.26.20
  • react-dnd: 11.1.3
  • react-dnd-html5-backend: 11.1.3

二、实现代码

为自定义的表格组件取名CustomColumnTable

拖拽方案说明

  • 在组件之外还得用DndProvider包裹,否则无法使用拖拽功能
  • 拖拽实现方案是拖拽表头列实现整列位置替换
  • 表头列既可以被拖拽也可以接受被拖拽的列
  • 缺陷:无法有效过滤 表格行可选择(rowSelection)时的选择列 和 固定列(fixed)

传入参数说明

这里是分了两种情况:

当外部组件传入的columns数组不会发生变化
  • 此时dynamicColumns为false,意味着之后表格列的拖拽排序与显示隐藏全由封装的CustomColumnTable组件来控制
columns数组会发生变化时
  • 比如在不同场景下,显示的表格列名不同,展示方式不同,这时外部组件传入的columns可能会发生变化,无法完全交由CustomColumnTable组件控制。此时外部组件传入columns时可以为每个子项添加selected属性,表示CustomColumnTable组件是否能控制该列的显示隐藏和拖拽
  • 同时外部组件需传递dynamicColumns onChangeColumn两个参数,且onChangeColumn函数的参数是已处理好的新columns数组,外部组件拿到后可以用来替换原columns数组

区分selectedvisible

  • selected表示CustomColumnTable组件是否能控制该列显示隐藏和拖拽位置,默认为truefalse表示CustomColumnTable组件暂时无法控制它
  • visible表示列是否显示,这由完全CustomColumnTable组件来控制,true显示,false隐藏
  • selected的优先级比visible高,在列的selected属性为false下,无论visible属性是否为true,表格都不会显示该列
import React, { useState, useRef } from 'react';
import { Button, Checkbox, Popover, Table } from 'antd';
import { createDndContext, DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

const type = 'DragTableHeadCol';
const DNDContext = createDndContext(HTML5Backend);

const swapArray = (arr, index1, index2) => {
  const dragCol = arr[index1];
  arr.splice(index1, 1);
  arr.splice(index2, 0, dragCol);
  return arr;
};

/**
 * @param  index    表头列的位置下标
 * @param  moveCol  拖拽结束时排序方法
 */
const dragdrop = (index, moveCol) => {
  const ref = React.useRef();
  const [, drop] = useDrop({
    accept: type,
    drop: item => {
      // item.index表示被拖拽组件的下标
      // index是接受被拖拽组件的下标
      moveCol(item.index, index);
    },
  });
  const [, drag] = useDrag({
    item: { type, index },
  });
  // 让组件既可以被拖拽也可以接受被拖拽组件
  drop(drag(ref));
  return { ref };
};

// 对tableHeadRow进行封装
const DragTableHeadRow = ({ children, moveCol }) => {
  // 对列进行处理,使其可拖拽
  const Ths = children.map((th, index) => {
    const {
      props: { className, style, children: thChildren },
    } = th;
    const { ref } = dragdrop(index, moveCol);

    const cloneTh = React.cloneElement(
      th,
      {
        ...th.props,
        ref,
        style: { cursor: 'move', ...style },
      },
      thChildren,
    );
    return cloneTh;
  });
  return <tr>{Ths}</tr>;
};

/**
 * @param {columns} 传入的列
 * @param {dynamicColumns} 若传入的columns是动态变化的(columns数组的元素有动态增减),传true
 * @param {onChangeColumn} 由外部管理列的变化 dynamicColumns = true时必传,参数为新的表格列
 */
const CustomColumnTable = ({
  columns,
  dynamicColumns = false,
  onChangeColumn,
  ...props
}) => {
  // 过滤不被选择的列(默认全选)
  const initColumns = columns.filter(item => item.selected ?? true);
  // dynamicColumns = true 下使用
  const visibleColumns = initColumns.filter(item => item.visible ?? true) || [];
  // 初始化nowColumns (dynamicColumns = false 下使用,默认全选)
  const [nowColumns, setColumns] = useState(initColumns.map(item => ({ visible: true, ...item })));
  // 真正渲染的列
  const realColumns = dynamicColumns
    ? visibleColumns
    : nowColumns.filter(item => item.visible) || [];
  // 可拖拽的列
  const dragColumns = dynamicColumns ? columns : nowColumns;
  // 初始化checkBoxChecked (dynamicColumns = false 下使用,默认全选)
  const [checkBoxChecked, setCheckBoxChecked] = useState(initColumns.map(item => item.title));
  // 真正显示列的title数组
  const realCheckBoxChecked = dynamicColumns
    ? visibleColumns.map(item => item.title)
    : checkBoxChecked;

  // 拖拽结束处理列位置的函数
  const moveCol = (dragIndex, hoverIndex) => {
    const newColumns = swapArray(
      dragColumns,
      dragColumns.findIndex(item => realColumns[dragIndex].title === item.title),
      dragColumns.findIndex(item => realColumns[hoverIndex].title === item.title),
    );
    if (dynamicColumns) {
      onChangeColumn(newColumns);
    } else {
      setColumns([].concat(newColumns));
    }
  };

  /** 
   * @param targetItem 点击的目标元素
   * @param visible    是否显示
   * @param list       显示(选中)的元素列表
   */
  const checkCol = (targetItem, visible, list) => {
    if (dynamicColumns) {
      const tempColumns = columns.map(item => {
        if (targetItem === item.title) return { ...item, visible };
        return item;
      });
      onChangeColumn(tempColumns);
    } else {
      setColumns(state =>
        state.map(item => {
          if (targetItem === item.title) return { ...item, visible };
          return item;
        }),
      );
      setCheckBoxChecked(list);
    }
  };

  /**
   * @param isCheckAll 是否全选或全不选
   */
  const checkAll = isCheckAll => {
    if (dynamicColumns) {
      const tempColumns = columns.map(item =>
        item.selected ?? true ? { ...item, visible: isCheckAll } : item,
      );
      onChangeColumn(tempColumns);
    } else {
      const checked = nowColumns.map(item => item.title);
      setCheckBoxChecked(isCheckAll ? checked : []);
      setColumns(state => state.map(item => ({ ...item, visible: isCheckAll })));
    }
  };

  const components = {
    header: {
      row: prop => {
        return <DragTableHeadRow {...prop} moveCol={moveCol} />;
      },
    },
  };

  const menu = (
    <>
      <Checkbox
        checked={realCheckBoxChecked.length === initColumns.length}
        indeterminate={
          realCheckBoxChecked.length !== initColumns.length && realCheckBoxChecked.length > 0
        }
        onClick={() => {
          const isCheckAll = realCheckBoxChecked.length !== initColumns.length;
          checkAll(isCheckAll);
        }}
      >
        全部
      </Checkbox>
      <Checkbox.Group
        style={{ width: '100%' }}
        value={realCheckBoxChecked}
        onChange={values => {
          if (values.length > realCheckBoxChecked.length) {
            const showItem = values.find(item => !realCheckBoxChecked.includes(item));
            checkCol(showItem, true, values);
          }
          if (values.length < realCheckBoxChecked.length) {
            const hideItem = realCheckBoxChecked.find(item => !values.includes(item));
            checkCol(hideItem, false, values);
          }
        }}
      >
        {initColumns.map(item => (
          <div key={item.title} style={{ minWidth: '200px' }}>
            <Checkbox value={item.title} disabled={item.title === '操作' || item.title === '序号'}>
              {item.title}
            </Checkbox>
          </div>
        ))}
      </Checkbox.Group>
    </>
  );

  const manager = useRef(DNDContext);

  return (
    <>
      <div style={{ textAlign: 'left', margin: '4px' }}>
        <Popover content={menu} placement="bottomLeft" trigger="click">
       	  <Button icon="filter" size="small" />
        </Popover>
       </div>
      <DndProvider manager={manager.current.dragDropManager}>
         <Table {...props} columns={realColumns} components={components} />
      </DndProvider>
    </>
  );
};

export default CustomColumnTable;

三、拓展

自定义接受组件的样式

react-dnduseDrop函数返回的第一个参数是其collect函数返回的对象,在collect函数里可以返回几个需要用到的属性
注意:这里添加了自定义类名drop-over

const dragdrop = (index, moveCol) => {
  const ref = React.useRef();
  const [{ isOver }, drop] = useDrop({
    accept: type,
    collect: monitor => {
      // 获取被拖拽的元素
      const { index: dragIndex } = monitor.getItem() || {};
      // 若被拖拽的元素和接受元素是同一个,则返回为空
      if (dragIndex === index) return {};
      return {
        // 返回 isOver
        isOver: monitor.isOver()
      };
    },
    drop: item => {
      moveCol(item.index, index);
    },
  });
  const [, drag] = useDrag({
    item: { type, index },
  });
  drop(drag(ref));
  // 返回 isOver
  return { ref, isOver };
};
 ...
 
const DragTableHeadRow = ({ children, moveCol }) => {
  // 对列进行处理,使其可拖拽
  const Ths = children.map((th, index) => {
    const {
      props: { className, style, children: thChildren },
    } = th;
    const { ref, isOver } = dragdrop(index, moveCol);

   // 拿到 isOver后,便可以用来判断添加自定义样式
    const cloneTh = React.cloneElement(
      th,
      {
        ...th.props,
        ref,
        className: `${className} ${isOver ? 'drop-over' : ''}`,
        style: { cursor: 'move', ...style },
      },
      thChildren,
    );
    return cloneTh;
  });
  return <tr>{Ths}</tr>;
};

自定义被拖拽组件样式

方法原理同上,这次使用到的是useDrag函数

const [{ isDragging }, drag] = useDrag({
    item: { type, index },
    collect: monitor => ({
      isDragging: monitor.isDragging(),
    }),
  });

四、实现效果

五、参考文章

对于react-dnd如何应用可参考 用 React Hooks 的方式使用 react-dnd