react+antd 可拖拽模态框组件

发布于:2025-07-20 ⋅ 阅读:(21) ⋅ 点赞:(0)

DraggableModal 可拖拽模态框组件使用说明

概述

DraggableModal 是一个基于 @dnd-kit/core 实现的可拖拽模态框组件,允许用户通过拖拽标题栏来移动模态框位置。该组件具有智能边界检测功能,确保模态框始终保持在可视区域内。

功能特性

  • 可拖拽移动:支持通过鼠标拖拽移动模态框位置
  • 智能边界检测:防止模态框被拖拽到屏幕可视区域外
  • 响应式适配:根据窗口大小自动调整可拖拽范围
  • 平滑交互:使用 CSS transform 实现流畅的拖拽动画
  • 类型安全:完整的 TypeScript 类型支持

安装依赖

确保项目中已安装以下依赖:

npm install @dnd-kit/core @dnd-kit/utilities
# 或
yarn add @dnd-kit/core @dnd-kit/utilities

基本用法

1. 导入组件

import DraggableModal from './components/DraggableModal';

2. 基础示例

import React, { useState } from 'react';
import { Modal } from 'antd';
import DraggableModal from './components/DraggableModal';

const ExampleComponent: React.FC = () => {
  const [visible, setVisible] = useState(false);

  return (
    <>
      <button onClick={() => setVisible(true)}>
        打开可拖拽模态框
      </button>
      
      <Modal
        title="可拖拽的模态框"
        open={visible}
        onCancel={() => setVisible(false)}
        modalRender={(modal) => (
          <DraggableModal>
            {modal}
          </DraggableModal>
        )}
      >
        <p>这是一个可以拖拽的模态框内容</p>
      </Modal>
    </>
  );
};

export default ExampleComponent;

3. 与 Antd Modal 结合使用

import React, { useState } from 'react';
import { Modal, Form, Input, Button } from 'antd';
import DraggableModal from '@/pages/StdFormEdit/components/DraggableModal';

const FormModal: React.FC = () => {
  const [visible, setVisible] = useState(false);
  const [form] = Form.useForm();

  const handleSubmit = async () => {
    try {
      const values = await form.validateFields();
      console.log('表单数据:', values);
      setVisible(false);
    } catch (error) {
      console.error('表单验证失败:', error);
    }
  };

  return (
    <>
      <Button type="primary" onClick={() => setVisible(true)}>
        打开表单模态框
      </Button>
      
      <Modal
        title="用户信息编辑"
        open={visible}
        onCancel={() => setVisible(false)}
        footer={[
          <Button key="cancel" onClick={() => setVisible(false)}>
            取消
          </Button>,
          <Button key="submit" type="primary" onClick={handleSubmit}>
            确定
          </Button>
        ]}
        modalRender={(modal) => (
          <DraggableModal>
            {modal}
          </DraggableModal>
        )}
      >
      <table ...>
      </Modal>
    </>
  );
};

export default FormModal;

API 说明

DraggableModal Props

属性 类型 默认值 说明
children ReactNode - 需要包装的模态框内容

组件内部实现细节

DraggableWrapper Props
属性 类型 说明
top number 模态框垂直位置偏移量
left number 模态框水平位置偏移量
children ReactNode 子组件内容
modalRef RefObject<HTMLDivElement> 模态框DOM引用

技术实现

核心特性

  1. 拖拽识别:自动识别具有 modal-header 类名的元素作为拖拽手柄
  2. 边界限制
    • 垂直方向:上边界 -100px,下边界为窗口高度减去模态框高度再减去100px
    • 水平方向:限制在窗口宽度范围内,保持模态框居中对称
  3. 位置计算:使用 CSS transform 属性实现位置变换,性能优异

边界检测算法

// 垂直边界检测
const needRemoveMinHeight = -100; // 上边界
const needRemoveMaxHeight = winH - 100 - modalRef.current.clientHeight; // 下边界

// 水平边界检测  
const needRemoveWidth = (winW - modalRef.current.clientWidth) / 2; // 左右对称边界

使用注意事项

1. 模态框结构要求

确保被包装的模态框包含具有 modal-header 类名的标题栏元素:

// ✅ 正确 - Antd Modal 自动包含 modal-header 类名
<Modal title="标题">内容</Modal>

// ❌ 错误 - 自定义模态框缺少 modal-header 类名
<div className="custom-modal">
  <div className="title">标题</div> {/* 缺少 modal-header 类名 */}
  <div>内容</div>
</div>

2. 性能优化建议

  • 避免在 DraggableModal 内部频繁更新状态
  • 对于复杂内容,建议使用 React.memo 优化子组件渲染
const OptimizedContent = React.memo(() => {
  return (
    <div>
      {/* 复杂内容 */}
    </div>
  );
});

<DraggableModal>
  <Modal title="优化示例">
    <OptimizedContent />
  </Modal>
</DraggableModal>

DraggableModal源码

import React, { useState, useRef, useLayoutEffect } from 'react';
import { DndContext, useDraggable } from '@dnd-kit/core';
import type { Coordinates } from '@dnd-kit/utilities';

const DraggableWrapper = (props: any) => {
    const { top, left, children: node, modalRef } = props;

    const { attributes, isDragging, listeners, setNodeRef, transform } = useDraggable({
        id: 'draggable-title'
    });

    const dragChildren = React.Children.map(node.props.children, (child) => {
        if (!child) {
            return child;
        }
        if (child.type === 'div' && child.props?.className?.indexOf('modal-header') >= 0) {
            return React.cloneElement(child, {
                'data-cypress': 'draggable-handle',
                style: { cursor: 'move' },
                ...listeners
            });
        }
        return child;
    });

    let offsetX = left;
    let offsetY = top;
    if (isDragging) {
        offsetX = left + (transform?.x ?? 0);
        offsetY = top + transform?.y;
    }

    return (
        <div
            ref={(el) => {
                setNodeRef(el);
                if (modalRef) modalRef.current = el;
            }}
            {...attributes}
            style={
                {
                    transform: `translate(${offsetX ?? 0}px, ${offsetY ?? 0}px)`
                } as React.CSSProperties
            }
        >
            {React.cloneElement(node, {}, dragChildren)}
        </div>
    );
};

const DraggableModal = (props: any) => {
    const [{ x, y }, setCoordinates] = useState<Coordinates>({
        x: 0,
        y: 0
    });
    const modalRef = useRef<HTMLDivElement>(null);
    const [modalSize, setModalSize] = useState({ width: 0, height: 0 });

    useLayoutEffect(() => {
        if (modalRef.current) {
            const rect = modalRef.current.getBoundingClientRect();
            setModalSize({ width: rect.width, height: rect.height });
        }
    }, [props.children]);

    return (
        <DndContext
            onDragEnd={({ delta }) => {
                const winW = window.innerWidth;
                const winH = window.innerHeight;
                const needRemoveMinHeight = -100;
                const needRemoveWidth = (winW - modalRef.current.clientWidth) / 2;
                const needRemoveMaxHeight = winH - 100 - modalRef.current.clientHeight;
                const newX = x + delta.x;
                const newY = y + delta.y;
                let curNewY = newY;
                if (newY < 0) {
                    curNewY = newY < needRemoveMinHeight ? needRemoveMinHeight : newY;
                } else {
                    curNewY = newY > needRemoveMaxHeight ? needRemoveMaxHeight : newY;
                }
                if (Math.abs(newX) < needRemoveWidth) {
                    setCoordinates({
                        x: newX,
                        y: curNewY
                    });
                } else {
                    setCoordinates({
                        x: newX < 0 ? 0 - needRemoveWidth : needRemoveWidth,
                        y: curNewY
                    });
                }
            }}
        >
            <DraggableWrapper top={y} left={x} modalRef={modalRef}>
                {props.children}
            </DraggableWrapper>
        </DndContext>
    );
};

export default DraggableModal;



3. 兼容性说明

  • 支持现代浏览器(Chrome 88+、Firefox 84+、Safari 14+)
  • 移动端暂不支持拖拽功能
  • 需要 React 16.8+ 版本支持

故障排除

常见问题

Q: 模态框无法拖拽?

A: 检查以下几点:

  1. 确保模态框标题栏包含 modal-header 类名
  2. 确认 @dnd-kit/core 依赖已正确安装
  3. 检查是否有其他元素阻止了拖拽事件

Q: 拖拽时出现跳跃现象?

A: 这通常是由于 CSS 样式冲突导致,确保没有其他 transform 样式影响模态框定位。

Q: 模态框被拖拽到屏幕外?

A: 组件内置了边界检测,如果出现此问题,请检查窗口大小变化时是否正确触发了重新计算。

版本历史

  • v1.0.0: 初始版本,支持基础拖拽功能和边界检测

网站公告

今日签到

点亮在社区的每一天
去签到