「变形」级联选择器实现方案

3,463 阅读4分钟

背景

综合某些业务的使用场景,理想的交互方式是类似于下面这样的。

01.gif

需求整理

  • 直接呈现可操作可选择的级联选择器,而非antd类似于表单中的选择器一样。 image.png
  • 高度自定义内容
    • 不同tabs下的级联选择联动
    • 自定义节点渲染
    • 子节点自定义合并

思路梳理

由于没有合适拿来即用的组件来实现,起初设想是在antd的基础上,通过一些css和js简单的二次封装一下来使用。

然而...

项目中的antd版本的级联选择器不支持可选项。

那就只能自己搞一个了。捋一下思路,其实也没那么难

最核心的逻辑:某个节点状态checked|unchecked发生变更后引起其他状态的变化

  • 对于当前节点所有子节点的状态变更
  • 对当前节点的所有祖先节点的变更

具体实现

Props

为了我们能通过表单元素方便的包裹起来,我们沿用value+onChange的参数,此外还需要如下一些参数。

interface NodeItemProps {
  /* 标签名称 */
  label: string
  value: string
  /* 子节点 */
  children?: Array<NodeItemProps>
  /* 层级 */
  levelType: number
  /* 父节点值 */
  parentValue: string
  render?: (...arg: any) => ReactElement
}
interface IProps {
  /* 数据源 */
  treeData: Array<NodeItemProps>
  /* 初始值 */
  value?: Array<string>
  onChange?: (list: Array<any>) => void
  disableKeys?: Array<string>
  defaultExpend?:boolean
}

层级展示

级联选择器的数据源和树🌲形组件的数据源是如出一辙的,只不过不同于树的交互和呈现:树的任意节点都可以进行折叠、收缩、选中等操作,而级联选择器的只呈现了当前展开节点的数据。因此我们只需要维护一个数据,用来存放当前展开的key就行。

image.png 比如上述状态,第一列是始终存在的,点击第一列数据(0105)的的时候,在我们维护的expendKeys中更新点击的值[0105],点击010501时,更新包含自身010501的祖先节点[0105,010501]以此类推 即 当我们点击某个节点时,需要把当前节点及当前节点的所有祖先节点更新。

那我们在渲染的时候,就对着节点进行遍历,渲染出节点下的childen即可。

<Row type='flex'>
    <Col>
      <div>{treeData.map((node: NodeItemProps) => renderOneNode(node))}</div>
    </Col>
    {expendKeys.map((val, parentIndex) => {
      const list = treeDataMap.get(val)?.children || []
      return (
        <Col key={val}>
          <div>
            {list.map((node: NodeItemProps) => renderOneNode(node))}
          </div>
        </Col>
      )
  })}
</Row>

节点选中的逻辑

image.png

其中比较比较繁琐的是当前节点选中后对于祖先节点的影响。

eg:

image.png

在上述场景下,选中了01040201后=》当前兄弟节点全部选中=》父节点选中,父节点选中后=》父节点的所有兄弟节点选中=〉父节点的父节点选中...

因此这里需要拿到该节点的所有祖先节点,然后对祖先节点进行遍历,来获取需要选中的父节点。

   const parentKeys = getAllParent(node, treeData)
   const appendParentKeys = []
  /* 对当前节点选中后,所有父节点的状态是否选中进行判断,得到需要添加的父节点的key */
  parentKeys.forEach(item => {
    const currentNode = treeDataMap.get(item)
    const sublingKeys = currentNode?.children?.map(v => v.value)
    const isParentCheck = sublingKeys?.every(v => [...value, item, ...appendParentKeys].includes(v))
    isParentCheck && appendParentKeys.push(item)
  })

节点半选中状态

所有的checkbox是受控的,即选中、非选中、 半选中、不可选等状态均是通过计算来设置的。 半选中的状态也比较好算

  • 子节点半选中,组件节点必定半选中
  • 子节点未没有全选中,也没有一个没选中。人话:选了一部分
 const getIndeterminate = (node: NodeItemProps, keys: Array<string>): boolean => {
  /* 子节点半选中,祖先节点也半选中 */
  if (isEmpty(node.children)) return false
  const isIncludeParent = keys.find(item => {
    return node.value !== item && item.startsWith(node.value)
  })
  if (isIncludeParent) return true
  const checkList = node.children?.filter((item: NodeItemProps) => {
    return keys.includes(item.value)
  })
  const unCheckList = node.children?.filter((item: NodeItemProps) => {
    return !keys.includes(item.value)
  })
  return !isEmpty(checkList) && !isEmpty(unCheckList)
}

// 获取节点状态
const getStatusByNode = useCallback(
(node: NodeItemProps): NodeStatusProps => {
  const checked = allKeys.includes(node.value)
  const indeterminate = getIndeterminate(node, value)
  const disabled = disableKeys.includes(node.value)
  return {
    checked,
    indeterminate,
    disabled,
  }
},
[value, disableKeys, allKeys]
)

其他

前面有提到过,当前项目中的antd版本不支持选中的功能。那我们结合Popover和自定义级联选择器,也可以实现类似于antd中的Cascader组件。

image.png

实现思路也比较简单包装一层Popover在层级选择器状态变化的时候,来处理显示就好了。

   <div id='cascaderForm' className={cls(Style.container)}>
        <Popover content={<DefCascader treeData={treeData} onChange={onCheckedChange} value={value} />} trigger='click'>
          <section className={cls(Style.mockInput)}>
            {value.length ? (
              value.map((item: string, index: number) => {
                if (index > maxCount) {
                  return null
                }
                if (index === maxCount) {
                  return (
                    <span className={cls(Style.baseTag)} key={item}>
                      ... +{value.length - maxCount}
                    </span>
                  )
                }
                return (
                  <div className={cls(Style.baseTag)} key={item}>
                    {item}
                    <Icon
                      type='close'
                      className={cls(Style.closeIcon)}
                      onClick={e => {
                        e.stopPropagation()
                        onDelete(item)
                      }}
                    />
                  </div>
                )
              })
            ) : (
              <span className={cls(Style.mockPlaceholder)}>{placeholder}</span>
            )}
          </section>
        </Popover>
      </div>

最后

实现的过程中,有一个体会就是:在数据计算比较复杂的场景下,一定要提取出一些工具类纯函数来处理。另外Map的结构也是比较好用的。

比如说一个树的结构 我们可以先flat拍平、在生成Map,那我们就可以很方便的查找节点。

 /* 树=>平铺数组 */
  @computed
  get flatTags() {
    return this.allTag.map((item: NodeItemProps) => getNodesFromRoot(item)).flat()
  }

  /* 标签Map<string,NodeItemProps>,方便查找 */
  @computed
  get allTagCodeMap() {
    return new Map(this.flatTags.map(it => [it.value, it]))
  }
  /* 层级分组 */
  @computed
  get groupKeys(){
  return lodash.groupBy(nodes, 'levelType')
  }

上述组件,虽然没有antd中的那么强大,不过在一些定制程度比较高的交互、呈现场景下,使用体验还是不错的。