手把手实现一个可拖拽的容器布局组件

1. 前言

某一天,产品经理给我提了这样一个需求:产品概览页是一个三列布局的结构,我希望用户能够自己拖动列与列之间的分割线,实现每列的宽度自定义,国际站用户就经常有这样的需求。效果类似这样:

就这?简单啊,不就是拖拽吗?使用开源拖拽库,回调里面给相关容器设置一下宽度即可,几行代码就搞定了。...不对,这是新同学才应该有的想法,但我是一个老前端啊,后来我又想了一下,如果我实现了上面的功能,那两列布局、三列布局、不管几列布局都应该可以拖拽啊,那页面左边的菜单,右边弹出的抽屉也可以让用户拖拽啊,嗯...那就做成一个组件吧,让我们来优雅的实现它。

2. 组件分析

我们先分析一下,不管是两列布局、三列布局、菜单、抽屉,最后拖拽的其实都是一根线,所以首先我们需要封装一个拖拽线条的组件,有了这个组件,再实现任何布局拖拽宽度自定义的功能就简单很多了:

使用开源库还是自己实现,也是我考虑的一个问题,我最终还是选择了自己实现,原因主要有两点:第一是现在的开源得三方包体积都比较大,我们的业务组件是项目必须引用的资源,资源当然是越小越好;第二是我们这个功能比较简单,自己实现代码可控,还可以实用一些新特性让性能做到最优。

3. DragLine

DragLine组件主要包括哪些能力呢?

  1. 内置拖拽能力,可配置拖拽开始和结束的回调函数。
  2. 内置提示信息,可配置是否在第一次渲染时默认进行"可拖拽"的信息提示。

废话不多说,直接上拖拽线条组件DragLine的代码:

js 复制代码
// DragLine.js
import React, { useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import './index.scss';


const DragLine = ((props) => {
  const {
    gap = 16,
    onMouseMove,
    onMouseUp,
    style = {},
    tipKey,
    defaultShowTip = false,
    ...rest
  } = props;
  const [visible, setVisible] = useState(defaultShowTip);
  const ref = useRef(null);
  const eventRef = useRef({});

  const closeNavTips = () => {
    localStorage.setItem(tipKey, 'true'); // 设置标记
    setVisible(false); // 关闭弹窗
  };

  // 拖拽结束
  const handleMouseUp = (e) => {
    document.body.classList.remove('dragging');
    onMouseUp && onMouseUp(e, ref.current);
    document.removeEventListener('mousemove', eventRef.current.mouseMoveHandler, false);
    document.removeEventListener('mouseup', eventRef.current.mouseUpHandler, false);
  };

  // 拖拽中
  const handleMouseMove = (e) => {
    onMouseMove && onMouseMove(e, ref.current);
  };

  // 开始拖拽
  const handleMouseDown = () => {
    closeNavTips();// 关闭拖拽提示框
    document.body.classList.add('dragging');
    eventRef.current.mouseMoveHandler = (e) => handleMouseMove(e);
    eventRef.current.mouseUpHandler = (e) => handleMouseUp(e);
    document.addEventListener('mousemove', eventRef.current.mouseMoveHandler, false);
    document.addEventListener('mouseup', eventRef.current.mouseUpHandler, false);
  };

  const line = (
    <div
      ref={ref}
      style={{
        '--drag-gap': `${gap}px`,
        ...style,
      }}
      className={`drag-line ${visible ? 'active' : ''}`}
      onMouseDown={handleMouseDown}
      {...rest}
    />
  );

  return visible ? (
    <Tooltip
      open
      placement="rightTop"
      title={(
        <div>
          <div style={{ marginBottom: 4 }}>拖动这根线试试~</div>
          <Button size="small" onClick={closeNavTips}>关闭</Button>
        </div>
      )}
    >
      {line}
    </Tooltip>
  ) : line;
});

export default DragLine;

对应的css代码如下:

css 复制代码
/* index.scss */
.drag-line {
  width: 2px;
  margin: 0 calc((var(--drag-gap, 16px) - 2px) / 2);
  background: transparent;
  cursor: col-resize;

  &.active, &:hover {
    background: blue;
  }
}

.dragging {
  user-select: none; // 内容不可选择
}

上述代码,拷贝后可以直接运行,我简单说明其中几点:

  1. js文件53行,使用到了css变量,对应css文件第4行,并通过calc函数可以实现很多复杂功能。
  2. js文件42行,拖拽时给body增加类名,对应css文件第14行,设置拖拽时body内容不可选中,不然用户会在拖拽时无意选中很多内容,从而造成困惑。
  3. 组件代码非常简单,并且内部已经封装好了拖拽能力,以及弹出的提示框,只是抛出了几个简单的API给业务方使用即可,我们还可以根据实际需求进一部分封装,比如线条的宽度、提示的内容和位置等等。

4. DragContainer

有了 DragLine 这个基础组件后,我们就可以很容易的去扩展任何需要拖拽的上层组件了,比如我们来实现一个可拖拽的多列布局容器组件,直接上DragContainer组件的源码:

js 复制代码
// DragContainer.js
import React, { useRef } from 'react';
import DragLine from '../DragLine';
import classnames from 'classnames';
import './index.scss';


const DragContainer = (props) => {
  const {
    className,
    sceneKey,
    minChildWidth = 150,
    contentList = [],
    gap = 16,
  } = props;

  const cls = classnames('drag-container', className);
  const ref = useRef(null);

  // 拖拽结束时,保存宽度信息
  const onMouseUp = () => {
    const widthList = contentList.map((_, i) => {
      const child = ref.current.querySelector(`.item${i}`);
      return `${child?.offsetWidth}px`;
    });
    localStorage.setItem(sceneKey, widthList.join('#'));
  };

  const onMouseMove = (event, node) => {
    const index = parseInt(node.getAttribute('data-index'));
    const leftElement = ref.current.querySelector(`.item${index}`);
    const rightElement = ref.current.querySelector(`.item${index + 1}`);

    // 拖动距离 = 分割线的位置 - 鼠标的位置
    const dragOffset = node.getBoundingClientRect().left - event.clientX;
    const newLeftChildWidth = leftElement.offsetWidth - dragOffset;
    const newRightChildWidth = rightElement.offsetWidth + dragOffset;

    if (newLeftChildWidth >= minChildWidth && newRightChildWidth >= minChildWidth) {
      ref.current.style.setProperty(`--drag-childWidth-${sceneKey}-${index}`, `${newLeftChildWidth}px`);
      ref.current.style.setProperty(`--drag-childWidth-${sceneKey}-${index + 1}`, `${newRightChildWidth}px`);
    }
  };


  const contentData = [];
  const localWidthList = localStorage.getItem(sceneKey)?.split('#') || []; // 获取本地已经保存的宽度信息
  contentList.forEach((d, i) => {
    contentData.push(
      <div
        key={`${sceneKey}_${i}`}
        className={`container-item item${i}`}
        style={{ flexBasis: `var(--drag-childWidth-${sceneKey}-${i}, ${localWidthList[i]})` }}
      >{d}
      </div>,
    );
    if (i < contentList.length - 1) {
      contentData.push(
        <DragLine
          key={`${sceneKey}_dragline_${i}`}
          onMouseMove={onMouseMove}
          onMouseUp={onMouseUp}
          tipKey="draggableContainerFlag"
          data-index={i}
          defaultShowTip={i === 0}
          gap={gap}
        />,
      );
    }
  });

  return (
    <div ref={ref} className={cls}>
      {contentData}
    </div>
  );
};


export default DragContainer;

对应样式文件如下:

css 复制代码
/* index.scss */
.drag-container {
  display: flex;
  align-items: stretch;
  width: 100%;

  .container-item {
    height: 100%;
    overflow: hidden;
    flex: 1; // 同比例放大缩小
  }
}

DragContainer组件的实现逻辑也比较简单,基本思路如下:

  1. 根据传入的contentList进行一个循环,如果不是最后一个child,则多渲染一个DragLine,用以拖拽。
  2. 在拖拽线条的回调函数里,进行一个拖拽偏移和左右子元素新宽度的计算,再设置到css变量中,从而实现拖拽宽度实时变化的效果。并且代码中没有用到任何React State,不需要重复渲染整个组件,改变宽度直接使用css实现,性能也比较好。
  3. css文件第10行,对flex布局的子元素设置flex: 1,意思是当我们拖动浏览器窗口大小时,子元素的宽度会同比例放大缩小,就能实现宽度自适应了,但这里有个前提是,子元素宽度不要写死,而是配合js文件第53行的flexBasis属性一起使用。
  4. 上述代码拷贝后也是可以直接运行的,需要的同学可以直接试试。

5. 使用效果

我们在业务代码中使用DragContainer组件写个例子,使用简单,效果完美:

js 复制代码
<DragContainer
  sceneKey="overview-page"
  contentList={[
    <Card>111</Card>,
    <Card>222</Card>,
    <Card>333</Card>,
  ]}
/>

6. 总结

其实本文我最想要表达的是,当我们接到一个需求之后,先学会分析和过滤,如果是特定的业务需求,实现即可,如果是通用类需求,就要慢慢学会从组件开发的角度去思考,是否能够举一反三,通过组件开发去覆盖解决更多的场景和问题。另外是在功能的实现方面,主要总结以下几点:

  1. 要能够通过对比选择最合适自己的技术,比如简单的拖拽功能完全可以使用原生js来做,而不是引入一个超大的三方包。
  2. 容器宽度的改变可以直接修改css属性,而不是使用React状态,减少不必要的重复渲染。
  3. css variable技术,是打通js和css的一种手段。
  4. flex布局相关属性的熟练使用,可以以更优的方案来解决一些布局问题。

这些都是在平常繁琐的业务开发中,主动要求和训练自己,慢慢形成的一种专业嗅觉。

相关推荐
new出一个对象3 小时前
uniapp接入BMapGL百度地图
javascript·百度·uni-app
你挚爱的强哥3 小时前
✅✅✅【Vue.js】sd.js基于jQuery Ajax最新原生完整版for凯哥API版本
javascript·vue.js·jquery
y先森4 小时前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy4 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189114 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿5 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡6 小时前
commitlint校验git提交信息
前端
虾球xz7 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇7 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒7 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript