likes
comments
collection
share

实现一个可拖拽分栏组件

作者站长头像
站长
· 阅读数 36

初步尝试

在实现之初的想法很简单,先实现一个二分栏功能的组件,页面主要元素有三个:左分栏,右分栏,分割线,全部使用 absolute 定位。

实现一个可拖拽分栏组件

实现样式预览

import { FC, useState } from 'react';
import styles from './index.module.scss';
import cn from 'classnames';

const ResizableCol: FC = () => {
  const [width, setWidth] = useState(100);

  return (
    <div className={styles.container}>
      { /** 左分栏 */ }
      <div className={cn(styles.leftside, styles.block)} style={{ width: `${width}px` }}></div>
      { /** 右分栏 */ }
      <div className={cn(styles.rightside, styles.block)} style={{ left: `${width}px` }}></div>
      { /** 分割线 */ }
      <div
        className={styles.divider}
        style={{ left: `${width}px` }}
      />
    </div>
  );
};

export default ResizableCol;
.container {
  position: relative;
  height: 100%;
}

.divider {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  height: 100%;
  background-color: #000;
  cursor: col-resize;
  z-index: 1;
}

.block {
  position: absolute;
  top: 0;
  bottom: 0;
}

.leftside {
  left: 0;
  background-color: #ddd;
}

.rightside {
  right: 0;
  background-color: #bbb;
}

添加交互、优化样式

在实现样式后,为组件补充相关交互代码,主要是为 onMouseDown / onMouseMove / onMouseUp 添加相应的事件处理函数。

其中:

  1. onMouseDown: 记录用户的点击位置,同时,如果发现有 onMouseUp 未正常触发的情况下,调用相关处理函数 handleMouseUp
  2. onMouseMove: 根据用户当前鼠标位置计算左右分栏的宽度,以及分割线的位置。
  3. onMouseUp: 清理数据。

另外 onMouseMoveonMouseUp 事件由外层的容器元素进行处理,主要是由于当用户鼠标滑动较快时,如果鼠标脱离了分割线元素,那么这两个事件就不会再继续触发了,由于分割线很窄,只有几个像素宽,所以这种情况是极有可能发生的,因此需要将这两个事件提升到父级容器来处理。

// 记录点击开始位置
const startXRef = useRef<number | null>(null)
// 记录左分栏的宽度
const [width, setWidth] = useState(100);
// 当分割线开始移动时,记录此时的左分栏宽度
const oldWidthRef = useRef(100);

// onMouseDown处理函数
const handleMouseDown = useCallback((e: React.MouseEvent) => {
  if (e.button === 0) {
    if (startXRef.current !== null) {
      handleMouseUp(e);
    }
    startXRef.current = e.clientX;
  }
}, []);

// onMouseMove处理函数
const handleMouseMove = useCallback((e: React.MouseEvent) => {
  if (startXRef.current === null) {
    return;
  }
  setWidth(e.clientX - startXRef.current + oldWidthRef.current);
}, [])

// onMouseUp处理函数
const handleMouseUp = useCallback((e: React.MouseEvent) => {
  if (e.button === 0) {
    startXRef.current = null;
    oldWidthRef.current = width;
  }
}, [width])

return (
  <div className={styles.container} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} style={{ 'cursor': startXRef.current !== null ? 'col-resize' : 'default' }}>
    <div className={cn(styles.leftside, styles.block)} style={{ width: `${width}px` }}></div>
    <div className={cn(styles.rightside, styles.block)} style={{ left: `${width}px` }}></div>
    <div
      className={styles.divider}
      style={{ left: `${width - 3}px` }}
      onMouseDown={handleMouseDown}
      draggable={false} />
  </div>
);

同时,如果分割线元素太窄(例如1个像素),用户很难选中分割线,因此将其宽度修改为7像素大小。

.divider {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  padding: 0 3px;
  height: 100%;
  cursor: col-resize;
  z-index: 1;

  &:after {
    display: inline-block;
    content: '';
    position: absolute;
    left: 3px;
    top: 0;
    bottom: 0;
    width: 1px;
    background-color: #000;
    z-index: -1;
  }
}

实现多分栏

将组件从二分栏拓展到三分栏、四分栏,在实现思路上和二分栏没有什么区别,同样是响应用户的交互后,去更新多个分栏的宽度。

同时这次,将不同分栏的内容改为由父组件传递的形式,因此 ResizableCol 现在可以接受以下的 props

export interface Props {
  /** 不同分栏的内容 */
  content: JSX.Element[];
  /** 不同分栏的默认宽度 */
  defaultWidth?: number[];
}

由于逻辑上并没有大的不同,所以就直接贴 ResizableCol 的代码了

import React, { FC, useState, useCallback, useRef } from 'react';
import styles from './index.module.scss';
import { Props } from './type';

const DefaultWidth = 100;

function isValidWidth(width: number) {
  return width > 0;
}

function cumsum(arr: number[], start: number, end?: number) {
  let result = 0;
  for (let i = start, j = end == null ? arr.length : end; i < j; i++) {
    result += arr[i];
  }
  return result;

}

function isUndef(val: any): val is (null | undefined) {
  return val === null || val === undefined;
}

/**
 * 可多分栏
 */
const ResizableCol: FC<Props> = props => {
  const { content, defaultWidth } = props;
  const colCount = content.length;

  const validDefaultWidth = (defaultWidth || []).map(width => isValidWidth(width) ? width : DefaultWidth);
  for (let i = validDefaultWidth.length; i < colCount - 1; i++) {
    validDefaultWidth.push(DefaultWidth)
  }

  const indexRef = useRef<number | null>(null);
  const [widthList, setWidthList] = useState(validDefaultWidth);
  const oldWidthRef = useRef(validDefaultWidth);
  const startClientXRef = useRef<number | null>(null)

  const handleMouseDown = useCallback((e: React.MouseEvent) => {
    if (e.button === 0) {
      if (startClientXRef.current !== null) {
        handleMouseUp(e);
      }

      startClientXRef.current = e.clientX;

      // 记录index
      const dividerEl = e.target as HTMLDivElement;
      const indexStr = dividerEl.dataset.index;
      if (!indexStr) {
        return;
      }

      const indexNum = Number(indexStr);
      if (isNaN(indexNum)) {
        return;
      }
      indexRef.current = indexNum;
    }
  }, []);

  const handleMouseMove = useCallback((e: React.MouseEvent) => {
    if (startClientXRef.current === null) {
      return;
    }
    const indexNum = indexRef.current;
    if (isUndef(indexNum)) {
      return;
    }

    setWidthList(widthList => {
      let newWidth = e.clientX - startClientXRef.current! + oldWidthRef.current[indexNum];
      newWidth = Math.max(Math.min(newWidth, 200), 100);

      if (newWidth === widthList[indexNum]) {
        return widthList;
      }

      const newList = [...widthList];
      newList[indexNum] = newWidth;
      return newList;
    });
  }, []);

  const handleMouseUp = useCallback((e: React.MouseEvent) => {
    if (e.button === 0) {
      startClientXRef.current = null;
      oldWidthRef.current = widthList;
    }
  }, [widthList])

  return (
    <div className={styles.container} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} style={{ 'cursor': startClientXRef.current !== null ? 'col-resize' : 'default' }}>
      {
        content.map((col, index) => {
          const left = cumsum(widthList, 0, index);
          const width = widthList[index];

          return (
            <>
              <div
                className={styles.block}
                style={{
                  left: `${left}px`,
                  width: index === colCount - 1 ? 'auto' : `${width}px`,
                  right: index === colCount - 1 ? '0px' : 'auto'
                }}
              >
                {col}
              </div>

              {
                index !== colCount - 1 ? (
                  <div
                    data-index={index}
                    className={styles.divider}
                    style={{ left: `${left + width - 3}px` }}
                    onMouseDown={handleMouseDown}
                    draggable={false} />
                ) : null
              }
            </>
          );
        })
      }
    </div>
  );
};

export default ResizableCol;

后续

在多分的基础上,仍旧需要补充一些组件交互上的限制,例如对于不同分栏的宽度限制(上面代码中将分栏的宽度限制在 100px 到 200px 之间),这些限制以及不同分栏之间宽度可能存在的联动关系可以按照自己的需求去实现。

以及需要考虑在性能上,目前这样的实现是否满足要求。