前端黑科技:按住Ctrl键或Shift键多选Table表格和Tree树节点的实现过程

2,351 阅读11分钟

我正在参与掘金创作者训练营第5期,点击了解活动详情

前言

这次还是继续做技术分享,把我们项目中遇到的黑科技都给你们聊聊,希望能让你们在以后的工作中都不会被类似的难题所困扰,也让你们见识见识我们需求的变态程度。其实如果你之前耐心地阅读过我的文章,你也能够知道我所输出的基本都是复杂的需求,几乎是不走寻常路的那种,把它总结输出成文就是为了帮助大家在遇到问题时能作参考,所以,这么好心的笔者真的值得你们去浏览点赞^_^

背景

这次要聊的属于技术实现,所以还是要先来聊一下背景,方便大家能清楚地知道我将要分享的主题。最近接了一个大的需求,就是将原本CAD提供的功能使用H5来重写功能并对其进行功能上的更多扩展,所以这个需求可以分享的技术难点和交互需求有点多,后续会接连输出文章分享,这里算是提前做个预告,绝对都是干货满满。

好了,又扯了点闲话,下面就来讲解这次主题产生的背景。对于antd提供的Table表格Tree树组件,本身就提供了多选和单选功能,但是我们产品需求就是不用,看不上,任性,需要我们开发实现使用键盘的CtrlShift按键实现数据的多选操作,希望达到的效果和电脑选文件一样,即:按住Ctrl键,鼠标点击就能多选;按住Shift键,鼠标选中开始和结束的数据,中间的数据也要被选中;而鼠标直接单击数据就只能单选

描述应该很清楚了,这个需求的交互是不是很骚气,我们就喜欢这种花里胡哨的交互,这样才能体现我们产品的牛皮之处。诶,别人有的我就是不用,我就要另寻他路,我就是美特斯邦威😂。

下面先来看看antdTable表格Tree树组件提供的多选功能,如下:

gif3.gif

gif4.gif

好,下面就来讲述我是怎么使用CtrlShift按键来实现上面交互效果的→

实现过程

其实我项目中是表格结构和树结构2种方式在一个页面中通过按钮切换显示的,只是这2种类型在表现方式上的交互又不同时一致,所以这里就需要分两种类型来聊实现过程。还得温馨提示一下,该项目依然是React技术栈,所以使用其他技术栈的小伙伴们参考思路即可,是React技术栈的小伙伴就直接复制代码结合自身逻辑处理使用。下面就听我细细道来🙏

一、Table表格类型

先来说Table表格类型的实现过程。这里要额外提一嘴,由于我们数据量很大再加上有些需求很奇葩,如果使用antd提供的表格组件实现需求有出入,所以就选择自行使用div组装成表格样式,如果你是使用的Table表格组件,对于我将要说的实现过程是没有问题的,我会在后面给你温馨提示,让你也可以完美实现。

好,话不多说,直接上手码砖→

1.1 代码实现

先看我自己用div组装成表格样式的布局代码,在表示行的div盒子上增加一个点击事件,如下:

image.png

在编写具体的点击事件代码前,需要先定义一些state用于处理逻辑,如下:

// 鼠标选中的值
const [selectItems, setSelectItems] = useState<any[]>([]);

// 列表shift开始位置
const shiftStartKey = useRef<any>(null);

selectItems用于存储选中的值,shiftStartKey是用于记录用户是否按下了Shift按键。

然后,下面来编写点击事件代码,还是一步步来吧,最后在贴出完整的代码。xdm,马上开车,扶稳跟上了→

我们知道,React的事件是合成事件,所以我们这里需要获得用户按下键盘的键值就不需要像原生一样需要去获得键值code,可以通过event.nativeEvent获得一些按键事件,所以我们要实现CtrlShift实现多选就可以像下面这样获取,如下:

const {nativeEvent: { ctrlKey, shiftKey }} = event;

在拿到了CtrlShift的间值后,就可以编写单选,多选逻辑了。

首先来处理单选情况,即鼠标点击每一行时,只允许单选,如下:

// 单选
if (!ctrlKey && !shiftKey) {
    shiftStartKey.current = key;
    setSelectItems([record]);
    return;
}

单选功能就是要排除用户是否按下了CtrlShift键,没有按下其中一个键,我们就认为它是进行的单选操作。

多选功能有包括两种情况

  1. 用户按住Ctrl键,用鼠标挨着一个个选择,最后形成多选;

  2. 用户按住Shift键,用户只需要选择开始和结束数据,中间的数据自动被多选上。

所以真多上述2种情况,我们分别编写相应的逻辑代码,如下:

首先来看按住Ctrl键实现多选的逻辑代码:

// 按住ctrl多选
if (ctrlKey) {
    shiftStartKey.current = key;
    // 先选目录再选目录下的图纸,图纸不能选上
    if (selectItems.every(v => v.level === 0) && level !== 0) {
        return;
    }
    // 先选图纸再选目录,目录不能选上
    if (selectItems.some(v => v.level > 0) && level === 0) {
        return;
    }
    let arr: any[] = [...selectItems];
    let list: any[] = [];
    if (selectItems.length === 0) {
        setSelectItems([record]);
        return;
    }
    if (arr.map(v => v.key).includes(key)) {
        list = arr.filter(v => v.key != key);
    } else {
        list = [...arr, record];
    }
    setSelectItems(list);
    return;
}

需要将选中的节点的key赋值给之前定义记录Shift键的开始位置,这样在用Shift键实现多选时指明了开始位置,然后就是平时编写多选时的常规逻辑,不断的将选中的节点存储进selectItems集合中,这样就实现了Ctrl键多选功能。

其次再来看Shift键多选功能代码:

// 按住shift多选
if (shiftKey) {
    if (shiftStartKey.current === '') {
        return;
    }
    // 先选目录再选目录下的图纸,图纸不能选上
    if (selectItems.every(v => v.level === 0) && level !== 0) {
        return;
    }
    // 先选图纸再选目录,目录不能选上
    if (selectItems.some(v => v.level > 0) && level === 0) {
        return;
    }
    let list: any[] = [];
    // 第一次点击位置
    let firstClickIndex = dataSource.findIndex((v: any) => v.key === shiftStartKey.current);
    // 第二次点击位置
    let secondClickIndex = dataSource.findIndex((v: any) => v.key === key);
    if (firstClickIndex < secondClickIndex) {//  从上往下点
        list = dataSource.slice(firstClickIndex, secondClickIndex + 1);
    } else { //  从下往上点,
        list = dataSource.slice(secondClickIndex, firstClickIndex + 1);
    }
    setSelectItems(list);
}

这里可以看到,Shift键多选的代码比Ctrl键多选的代码稍微复杂一点。主要是要判断用户上第一次点击的位置和第二次点击的位置是怎么个顺序:如果是用户是自上而下点击选择节点,那获得的数据就是从开始位置的数据开始,到第二次机点击位置+1结束的中间所有数据,要把开始位置的数据排除,所以使用的是slice(电脑选文件也是把第一次点击位置的元素排除了的);而如果是自下而上,slice的范围则是secondClickIndexfirstClickIndex + 1的中间所有数据。期间你还可以使用短路判断哪些数据不能被选择,就像我上面贴出的代码一样。

到这里,文字结合代码我想我应该是把这个实现过程讲清楚了的吧。你品品,细品一下。下面就来展示完整代码,如下:

/**
 * 点击多选表格行
 * @param event
 * @param record
 */
const clickTableItem = (event: any, record: any) => {
    const {key, level} = record;
    const {nativeEvent: { ctrlKey, shiftKey }} = event;
    // 单选
    if (!ctrlKey && !shiftKey) {
        shiftStartKey.current = key;
        setSelectItems([record]);
        return;
    }
    // 按住ctrl多选
    if (ctrlKey) {
        shiftStartKey.current = key;
        // 先选目录再选目录下的图纸,图纸不能选上
        if (selectItems.every(v => v.level === 0) && level !== 0) {
            return;
        }
        // 先选图纸再选目录,目录不能选上
        if (selectItems.some(v => v.level > 0) && level === 0) {
            return;
        }
        let arr: any[] = [...selectItems];
        let list: any[] = [];
        if (selectItems.length === 0) {
            setSelectItems([record]);
            return;
        }
        if (arr.map(v => v.key).includes(key)) {
            list = arr.filter(v => v.key != key);
        } else {
            list = [...arr, record];
        }
        setSelectItems(list);
        return;
    }
    // 按住shift多选
    if (shiftKey) {
        if (shiftStartKey.current === '') {
            return;
        }
        // 先选目录再选目录下的图纸,图纸不能选上
        if (selectItems.every(v => v.level === 0) && level !== 0) {
            return;
        }
        // 先选图纸再选目录,目录不能选上
        if (selectItems.some(v => v.level > 0) && level === 0) {
            return;
        }
        let list: any[] = [];
        // 第一次点击位置
        let firstClickIndex = dataSource.findIndex((v: any) => v.key === shiftStartKey.current);
        // 第二次点击位置
        let secondClickIndex = dataSource.findIndex((v: any) => v.key === key);
        if (firstClickIndex < secondClickIndex) {//  从上往下点
            list = dataSource.slice(firstClickIndex, secondClickIndex + 1);
        } else { //  从下往上点,
            list = dataSource.slice(secondClickIndex, firstClickIndex + 1);
        }
        setSelectItems(list);
    }
}

1.2 实现效果

好了,代码编写完,就来看看执行代码后的效果,如下:

gif5.gif

看到最后效果,是不是挺丝滑的,完美的完成了需求所要求的功能,为自己鼓掌👏

二、Tree树类型

Table表格类型的效果开了一个好头,接下来就趁热打铁,紧接着实现Tree树类型的类似功能。树类型的CtrlShift多选功能,和Table表格的大同小异,就看需求加不加戏。所谓的加戏就是我做的需求不需要选中父节点时自动展开子节点并选中子节点,也没有要求实现Shift的多选功能,但是作为程序猿,没有戏也要自己加点戏,也给它安排上,未雨绸缪。

好,下面就开始介绍Tree树类型的CtrlShift多选功能的实现过程。又要发车了,速度跟上啊🚌

2.1 代码实现

Tree组件就不需要自己手写组装了,就使用antd提供的组件就好。先来介绍不需要Shift键和选中父节点也要展开选中子节点的方式,布局代码如下:

微信截图_20220729133138.png

这里的树类型虽说不需要自己手写,但还是需要自定义。因为tree组件本身也是不满足我们实际需求的,所以我对其节点进行了自定义处理,如下:

image.png

接着就是需要去定义变量,用于存储数据了,选中的集合还是使用的selectItems,因为是在同一页面使用,没有必要再去定义一个集合了,就再定义一个选中的keys集合,用于显示选中样式即可。

// 树形选中的keys集合
const [selectKeys, setSelectKeys] = useState<any[]>([]);

获取Crtl还是可以通过event.nativeEvent获得一些按键事件;至于节点的数据,antdTree组件给我们提供了参数的,如下:

const { nativeEvent: { ctrlKey }, selectedNodes, node} = info || {};
const {level, key} = node.dataRef || {};

没错,Tree组件提供的数据就是info对象(自己可以命名),然后我们对其进行结构会得到selectedNodesnode,其中selectedNodes是选中的节点集合,而node则是当前点击的那一个节点对象。

这里就要提到为什么我说我自己自定义它的节点了,如果你没有自定义节点的可以直接通过node获取到节点的数据,如果你像我一样自定义了节点的,就需要再一次解构node才能得到节点数据。所以上面你看到的获取节点数据时是通过node.dataRef,这是需要注意的一点(重要!!!

好了,接下来就开始深入。还是分为单选和多选2种情况来考虑,先来说单选的,和Table表格的一样,如下:

// 单选
setSelectKeys([key]);
setSelectItems([node.dataRef]);

单选就是把当前选择节点的key记录,记录的目的就是为了样式高亮选中。然后就是把选中的节点的数据往存储选中节点集合里扔就行了。

然后来说Ctrl多选,这个也很简单,就是看选择节点keys集合里面有没有它,有再选就是取消选中;没有,就遍历selectedNodes,把每个节点的dataRef数据取出来赋值给选择节点集合selectItems就可以了(没有自定义树节点的小伙伴直接将selectedNodes赋值给selectItems就行),如下:

// 按住ctrlKey多选
let arr = selectedNodes.map(v => v.dataRef);
if (ctrlKey) {
    // 先选目录再选目录下的图纸,图纸不能选上
    if (selectItems.every(v => v.level === 0) && level !== 0) {
        return;
    }
    // 先选图纸再选目录,目录不能选上
    if (selectItems.some(v => v.level > 0) && level === 0) {
        return;
    }

    // 取消选中
    if (selectKeys.includes(key)) {
        setSelectKeys(selectKeys.filter((v: any) => v !== key));
        setSelectItems(arr);
        return;
    }

    // 选中
    setSelectKeys(keys);
    setSelectItems(arr);
    return;
}

好了,树类型按住Ctrl键实现多选的功能代码就完了。下面来看完整代码,如下:

/**
 * 选择树
 * @param keys
 * @param info
 */
const selectTreeItem = (keys: any, info: any) => {
    const { nativeEvent: { ctrlKey }, selectedNodes, node} = info || {};
    const {level, key} = node.dataRef || {};
    let arr = selectedNodes.map(v => v.dataRef);
    // 按住ctrlKey多选
    if (ctrlKey) {
        // 先选目录再选目录下的图纸,图纸不能选上
        if (selectItems.every(v => v.level === 0) && level !== 0) {
            return;
        }
        // 先选图纸再选目录,目录不能选上
        if (selectItems.some(v => v.level > 0) && level === 0) {
            return;
        }

        // 取消选中
        if (selectKeys.includes(key)) {
            setSelectKeys(selectKeys.filter((v: any) => v !== key));
            setSelectItems(arr);
            return;
        }

        // 选中
        setSelectKeys(keys);
        setSelectItems(arr);
        return;
    }

    // 单选
    setSelectKeys([key]);
    setSelectItems([node.dataRef]);
}

下面再来说我们加戏的实现过程,即:其一,选中父节点就展开子节点并选中子节点;其二,增加Shift按键多选。这里就不用上述页面来介绍,用之前一个页面来演示,如下:

首先说展开的戏,点击收起的父节点,就需要将其展开,这里Tree组件中要增加expandedKeysonExpand 属性才能展开树节点,所以需要定义一个state,额外再定义onExpand方法如下:

// 展开节点
const [expandedKeys, setExpandedKeys] = useState<any[]>([]);
/**
 * 展开收起节点
 * @param expandedKeysValue 
 */
const onExpand = (expandedKeysValue: React.Key[]) => {
    setExpandedKeys(expandedKeysValue);
};

然后通过选择树节点的方法提供的数据,用节点的expanded值判断是否能将其展开,如下:

const onSelect = (keys: any, info: any) => {
    const {nativeEvent: { ctrlKey, shiftKey }, selectedNodes, node} = info || {};
    const {TreeNodeType, key, expanded, children = [], ChildMenuData, CurrentNodeId} = node || {};
    // 选中收起的节点,需要展开
    if (!expanded) {
        let newExpandedKeys = [...expandedKeys];
        newExpandedKeys.push(key);
        setExpandedKeys(newExpandedKeys);
    }
}

好,上面就是展开节点的逻辑代码,接下来排第二出戏。

按住Shift键可以选中开始和结束位置间的所有节点数据,这个功能和Table组件的功能类型,都需要记录第一次和第二次鼠标点击的位置,然后再来通过slice分割数据,同样也是需要记录Shift按键值的。如下:

// shiftStartKey 
const shiftStartKey = useRef<string>('');

接着就来处理按住Shift键之后多选的逻辑代码,如下:

// 按shift键多选
if (shiftKey) {

    if (shiftStartKey.current === '') {
        return;
    }

    // 展开树节点
    let newData = spreadTreeData(projectData);
    // 第一次点击位置
    let firstClickIndex = newData.findIndex((v: any) => v.CurrentNodeId === shiftStartKey.current);
    // 第一次点击元素
    const { ChildMenuData: firstClickNodeClidren = [], TreeNodeType: firstClickNodeTreeNodeType } = newData.find((v: any) => v.CurrentNodeId === shiftStartKey.current) || {};
    // 第二次点击位置
    let secondClickIndex = newData.findIndex((v: any) => v.CurrentNodeId === key);

    if (firstClickIndex < secondClickIndex) {  //  如果从上往下点, 第二次点击的位置是图纸,那么则以该图纸下图框的最后一个位置为结束位置
        if (TreeNodeType === 3) {
            secondClickIndex += ChildMenuData.length;
        }

    } else { //  如果从下往上点, 第一次点击的位置是图纸,那么则以该图纸下图框的最后一个位置为第一次点击位置
        if (firstClickNodeTreeNodeType === 3) {
            firstClickIndex += firstClickNodeClidren.length
        }

    }
    const [sortStartIndex, sortEndIndex] = [firstClickIndex, secondClickIndex].sort((firstClickIndex, secondClickIndex) => firstClickIndex - secondClickIndex);
    let shiftSelectedNode = newData.slice(sortStartIndex, sortEndIndex + 1);
    let shiftSelectedKeys = shiftSelectedNode.map((v: any) => { return v.CurrentNodeId });
    setSelectKeys(shiftSelectedKeys);
    setSelectNodes(shiftSelectedNode);
    return;
}

可以看到开始也是把树结构类型的数据通过spreadTreeData方法处理成了平铺类型的数组,然后再进行位置记录的,spreadTreeData方法如下:

/**
 * 展开树
 * @param data
 */
const spreadTreeData = (data: any[] = [], id: any = null): any[] => {
    let arr = [];
    data.forEach((v: any) => {
        if (v.ParentNodeId == id) {
            arr.push(v);
            arr = arr.concat(spreadTreeData(v.children, v.CurrentNodeId));
        }
    });
    return arr;
}

这里打个广告,如果还不清楚如何处理树结构数据的小伙伴,可以参考这篇文章(建议收藏)JS中5个处理树结构数据的常用方法及如何将其发布到npm上的教程讲解,顺便给我点点赞,栓Q🙏

回过来,就是和Table表格那里提到的一样,记录位置然后取数据,该处理其他的逻辑依然在里面使用短路规则处理即可。

到这里,树类型上按住CtrlShift键实现多选的实现过程也讲解完了,下面还是来看看具体的实现效果,以慰心安。

2.2 实现效果

首先是按住Ctrl键,没有Shift键,也没有展开子集的单选多选效果图,如下:

gif6.gif

然后是全都有💪的效果图,如下:

gif7.gif

最后,戏完人终,这篇文章要聊的主题就聊完了。如果你正苦于不知道处理这样的产品需求,不妨点进来看看我的实现思路,相信看完你一定会获得不小的收获。虽说产品需求在某些时候会让你崩溃无助,但是当你将其完美解决并呈现最终效果时,定会获得不小的成就之感,心情也就会拨开云雾见日出了,美极了😊。

往期精彩文章

后语

伙伴们,如果觉得本文对你有些许帮助,点个👍或者➕个关注再走呗^_^ 。另外如果本文章有问题或有不理解的部分,欢迎大家在评论区评论指出,我们一起讨论共勉。

src=http___p6.itc.cn_q_70_images03_20210104_70f8545500034a5bae5f1695a7ce3da0.jpeg&refer=http___p6.itc.webp