React版 容器拖拉拽UI组件,可嵌套任何节点

需求背景

产品需要某个模块实现拖拉拽功能,方便用户可以针对界面,可以动态快速拉伸看到更多内容,提升用户体验度

期望解决

如何在不影响当前界面的情况下(不动业务组件代码),我们的前端大大同学们,可以快速且无缺陷的完成这个需求呢,我们的容器UI组件应运而生, 实现极小成本的针对某个模块,针对节点扩展拖拉拽功能

前端痛点

需要实现产品的配置化功能,我们只需套用公共UI组件,即可实现,多处调用,只维护一个组件,来达到高内聚低耦合

实现思路

内部实现针对节点的拖拉拽事件,组件功能集成在hooks中,方便外部消费,一个方法,专注一个功能,尽量保证代码的干净、健壮、可维护、可扩展 外部只需传入children,我们内部,根据在这个 children 的右侧增加一个拖拽节点,来达到整个节点可以拖拽偏移的效果,以及一个容器节点,包裹住整个children,方便我们内部组件识别宽度,以及一些定位操作 活不多说,我们直接上代码 例子

js 复制代码
 <ResizableContainer minWidth={220} maxWidth={400} initialWidth={250}>
 xxxx      
 </ResizableContainer>
js 复制代码
import { throttle } from 'lodash';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';

type TCustomWidth<N = number> = Partial<{
  // 最小宽度
  minWidth: N;
  // 最大宽度
  maxWidth: N;
  // 初始化宽度
  initialWidth: N | undefined;
  // 是否开启拖拽遮罩, 默认为真
  isShowMask: boolean;
  // 是否实时更新宽度, 默认为真
  realtimeUpdate: boolean;
}>;

type TUseSizeProps = Partial<TFilterContainerProps> & TCustomWidth;

type TFilterContainerProps = {
  // 传入的拖拽元素节点
  children: React.ReactNode;
  // 是否开启拖拽
  isDraggable: boolean;
  // 拖拽列宽回调
  handleOnChangeFilterWidth?: (width: string | number) => void | undefined;
};

// 定义一个子组件可以接收的props类型
type TChildWithWidthProps = {
  newWidth?: number;
  [key: string]: any; // 允许其他任意属性
}

export const useResizableWidth = ({
  minWidth = 100,
  maxWidth = 800,
  handleOnChangeFilterWidth,
}: TUseSizeProps) => {
  const resizableRef = useRef<HTMLDivElement>(null);
  const resizeContainerRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState<number | undefined>();
  const [isResizing, setIsResizing] = useState(false);
  const [showMask, setShowMask] = useState(false);
  const [visualWidth, setVisualWidth] = useState<number | undefined>(); // 新增视觉宽度状态

  // 使用 ref 存储拖拽相关数据
  const dragInfo = useRef({
    startX: 0,
    startWidth: 0,
    lastUpdateTime: 0,
  });

  // 实时更新视觉宽度,但不触发回调
  const handleResize = useCallback(
    throttle((event: MouseEvent) => {
      if (!isResizing || !resizeContainerRef.current) return;

      const currentTime = Date.now();
      if (currentTime - dragInfo.current.lastUpdateTime < 16) {
        return;
      }

      const { left } = resizeContainerRef.current.getBoundingClientRect();
      const newWidth = Math.max(minWidth, Math.min(maxWidth, event.clientX - left));

      // 更新视觉宽度(实时变化)
      setVisualWidth(newWidth);
      dragInfo.current.lastUpdateTime = currentTime;
    }, 16),
    [isResizing, minWidth, maxWidth, width],
  );

  const handleResizeStart = useCallback((event: MouseEvent) => {
    event.preventDefault();
    event.stopPropagation();

    if (!resizeContainerRef.current) return;

    const rect = resizeContainerRef.current.getBoundingClientRect();
    dragInfo.current = {
      startX: event.clientX,
      startWidth: rect.width,
      lastUpdateTime: Date.now(),
    };

    // 初始化视觉宽度
    setVisualWidth(rect.width);
    setIsResizing(true);
    setShowMask(true);
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
  }, []);

  const handleResizeEnd = useCallback(() => {
    setIsResizing(false);
    setShowMask(false);
    document.body.style.cursor = '';
    document.body.style.userSelect = '';

    // 拖拽结束时更新实际宽度并触发回调
    if (resizeContainerRef.current && visualWidth) {
      setWidth(visualWidth);
      handleOnChangeFilterWidth?.(visualWidth);
    }
    setVisualWidth(undefined); // 重置视觉宽度
  }, [visualWidth, handleOnChangeFilterWidth]);

  useEffect(() => {
    const resizableElement = resizableRef.current;
    if (!resizableElement) return;

    resizableElement.addEventListener('mousedown', handleResizeStart);

    if (isResizing) {
      document.addEventListener('mousemove', handleResize);
      document.addEventListener('mouseup', handleResizeEnd);
      document.addEventListener('mouseleave', handleResizeEnd);
    }

    return () => {
      resizableElement.removeEventListener('mousedown', handleResizeStart);
      document.removeEventListener('mousemove', handleResize);
      document.removeEventListener('mouseup', handleResizeEnd);
      document.removeEventListener('mouseleave', handleResizeEnd);
      handleResize.cancel();
    };
  }, [isResizing, handleResize, handleResizeStart, handleResizeEnd]);

  return {
    ref: resizableRef,
    width,
    resizeContainerRef,
    showMask,
    visualWidth,
  };
};

export const ResizableContainer = memo(
  ({
    children,
    isDraggable = true,
    handleOnChangeFilterWidth,
    minWidth,
    maxWidth,
    initialWidth,
    isShowMask = true,
    realtimeUpdate = false,
  }: TUseSizeProps) => {
    const { width, ref, resizeContainerRef, showMask, visualWidth } = useResizableWidth({
      minWidth,
      maxWidth,
      handleOnChangeFilterWidth,
    });

    return (
      <>
        {/* 主容器 - 保持相对定位 */}
        <div
          style={{
            position: 'relative',
            height: '100%',
            width: `${
              realtimeUpdate ? visualWidth || width || initialWidth : width || initialWidth
            }px`,
          }}
        >
          {/* 可调整大小的内容区域 */}
          <div
            ref={resizeContainerRef}
            style={{
              width: `${visualWidth || width || initialWidth}px`,
              position: 'relative',
              top: 0,
              left: 0,
              background: '#fff',
              zIndex: 10, 
              height: '100%',
            }}
          >
            {React.Children.map(children, child =>
              React.isValidElement<TChildWithWidthProps>(child)
                ? React.cloneElement(child, {
                    newWidth: width,
                  })
                : null,
            )}

            {/* 拖拽手柄 */}
            {isDraggable && (
              <div
                ref={ref}
                style={{
                  width: '5px',
                  height: '100%',
                  backgroundColor: 'transparent',
                  position: 'absolute',
                  top: 0,
                  right: '-4px',
                  cursor: 'col-resize',
                  zIndex: 20,
                  touchAction: 'none',
                }}
              />
            )}
          </div>
          {/* 遮罩层  */}
          {isShowMask && showMask && (
            <div
              style={{
                position: 'fixed',
                top: 0,
                left: 0, // 覆盖整个视口
                right: 0,
                bottom: 0,
                backgroundColor: 'rgba(0, 0, 0, 0.4)',
                zIndex: 5,
              }}
            />
          )}
        </div>
      </>
    );
  },
);
相关推荐
Mintopia2 分钟前
Three.js 形变动画(Morph Target Animation):让 3D 模型跳起变形之舞
前端·javascript·three.js
清幽竹客3 分钟前
vue-11(命名路由和命名视图)
前端·vue.js
sg_knight4 分钟前
Flutter嵌入式开发实战 ——从树莓派到智能家居控制面板,打造工业级交互终端
android·前端·flutter·ios·智能家居·跨平台
陈_杨10 分钟前
鸿蒙5开发宝藏案例分享---切面编程实战揭秘
前端
喵手16 分钟前
CSS3 渐变、阴影和遮罩的使用
前端·css·css3
顽强d石头18 分钟前
bug:undefined is not iterable (cannot read property Symbol(Symbol.iterator))
前端·bug
烛阴27 分钟前
模块/命名空间/全局类型如何共存?TS声明空间终极生存指南
前端·javascript·typescript
火车叼位31 分钟前
Git 精准移植代码:cherry-pick 简单说明
前端·git
江城开朗的豌豆35 分钟前
JavaScript篇:移动端点击的300ms魔咒:你以为用户手抖?其实是浏览器在搞事情!
前端·javascript·面试
华洛41 分钟前
聊聊我们公司的AI应用工程师每天都干啥?
前端·javascript·vue.js