You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让React自定义Draggable组件的onDrop事件生效(兼容触摸与鼠标)

问题分析

你遇到的核心问题是:原生HTML5的onDrop事件只属于原生拖拽API体系,只有当元素开启draggable="true"并遵循原生拖拽流程(dragstart/dragover等)时才会触发。而你现在是用鼠标/触摸事件手动实现的拖拽逻辑,完全独立于原生拖拽API,所以容器上的onDrop自然不会被触发。

解决方案

既然不用原生draggable属性,我们需要手动模拟drop逻辑:在拖拽结束(mouseup/touchend)时,获取当前鼠标/触摸点的位置,判断该点是否落在目标Drapable容器内,然后手动执行元素重排的逻辑。

以下是修改后的完整代码,关键改动已标注:

import { useEffect, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, useRef, useState, ReactElement } from "react"

interface IDraggableProps {
    children: ReactElement[] | ReactElement | string;
    item: IItem; // 新增:传递当前拖拽的item数据
    onDragEnd: (position: {x: number, y: number}) => void; // 新增:拖拽结束时通知父组件落点
}

const Draggable = (props: IDraggableProps) => {
    const [isDragging, setIsDragging] = useState(false);
    const [xTranslate, setXTranslate] = useState(0);
    const [yTranslate, setYTranslate] = useState(0);
    const [initialMousePosition, setInitialMousePosition] = useState({ x: 0, y: 0 });
    const initialWidth = useRef<number>(0);

    const onMouseDown = (e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => {
        setInitialMousePosition({ x: e.clientX, y: e.clientY });
        setIsDragging(true);
    };

    const onTouchStart = (e: ReactTouchEvent<HTMLDivElement>) => {
        setInitialMousePosition({ x: e.touches[0].clientX, y: e.touches[0].clientY });
        setIsDragging(true);
        e.preventDefault(); // 新增:阻止触摸默认行为,避免页面滚动干扰
    };

    const onMouseMove = (e: MouseEvent) => {
        if (!isDragging) return;
        setXTranslate(prev => prev + e.clientX - initialMousePosition.x);
        setYTranslate(prev => prev + e.clientY - initialMousePosition.y);
        setInitialMousePosition({x: e.clientX, y: e.clientY}); // 修复:更新初始位置,避免位移跳变
    };

    const onTouchMove = (e: TouchEvent) => {
        if (!isDragging) return;
        const touch = e.touches[0];
        setXTranslate(prev => prev + touch.clientX - initialMousePosition.x);
        setYTranslate(prev => prev + touch.clientY - initialMousePosition.y);
        setInitialMousePosition({x: touch.clientX, y: touch.clientY}); // 修复:同上
        e.preventDefault(); // 新增:阻止触摸滚动
    };

    // 拖拽结束处理
    const handleDragEnd = (clientX: number, clientY: number) => {
        setIsDragging(false);
        // 通知父组件当前落点位置
        props.onDragEnd({x: clientX, y: clientY});
    };

    useEffect(() => {
        if (isDragging) {
            window.addEventListener("mousemove", onMouseMove);
            window.addEventListener("touchmove", onTouchMove);
        }
        return () => {
            window.removeEventListener("mousemove", onMouseMove);
            window.removeEventListener("touchmove", onTouchMove);
        }
    }, [isDragging, initialMousePosition]);

    useEffect(() => {
        const onMouseUp = (e: MouseEvent) => handleDragEnd(e.clientX, e.clientY);
        const onTouchEnd = (e: TouchEvent) => {
            if (e.changedTouches[0]) {
                handleDragEnd(e.changedTouches[0].clientX, e.changedTouches[0].clientY);
            }
        };

        window.addEventListener("mouseup", onMouseUp);
        window.addEventListener("touchend", onTouchEnd);
        return () => {
            window.removeEventListener("mouseup", onMouseUp);
            window.removeEventListener("touchend", onTouchEnd);
        }
    }, []);

    useEffect(() => {
        if (!isDragging) { // 修复:只有拖拽结束时重置位移
            setXTranslate(0);
            setYTranslate(0);
        }
    }, [isDragging])

    return (
        <div className="relative">
            <div
                style={isDragging ? {
                    width: initialWidth.current + "px",
                    transform: `translate(${xTranslate}px,${yTranslate}px)`,
                    position: "absolute",
                    zIndex: 100, // 新增:拖拽时提升层级,避免被遮挡
                    pointerEvents: "none" // 新增:拖拽时禁用自身点击事件,避免干扰落点判断
                } : undefined}
                onMouseDown={onMouseDown}
                onTouchStart={onTouchStart}
                ref={(e) => initialWidth.current = e?.offsetWidth!}
                className={`${isDragging ? "cursor-grabbing" : "transition-all cursor-grab"} static w-full flex justify-center items-center text-xl font-bold text-primary p-2 shadow-md border bg-slate-50`}>
                {props.children}
            </div>
        </div>
    );
}

interface IItem {
    data: string;
    row: number;
    id: string; // 新增:给每个item添加唯一ID,避免map用index做key的问题
}

const MyTasks = () => {
    const [data, setData] = useState<IItem[]>([
        { id: "item-1", row: 1, data: "item-1" },
        { id: "item-2", row: 1, data: "item-2" },
        { id: "item-3", row: 1, data: "item-3" },
        { id: "item-4", row: 1, data: "item-4" },
        { id: "item-5", row: 2, data: "item-5" },
        { id: "item-6", row: 2, data: "item-6" },
        { id: "item-7", row: 1, data: "item-7" }
    ]);
    const [draggingItem, setDraggingItem] = useState<IItem | null>(null);
    // 存储所有Drapable容器的ref和对应的row
    const containerRefs = useRef<Map<number, HTMLDivElement>>(new Map());

    // 处理拖拽结束逻辑
    const handleDragEnd = (position: {x: number, y: number}, item: IItem) => {
        if (!draggingItem) return;

        // 遍历所有容器,判断落点在哪个容器内
        for (const [row, container] of containerRefs.current.entries()) {
            const rect = container.getBoundingClientRect();
            const isInContainer = 
                position.x >= rect.left && 
                position.x <= rect.right && 
                position.y >= rect.top && 
                position.y <= rect.bottom;

            if (isInContainer && row !== item.row) {
                // 更新item的row,实现重排
                setData(prev => prev.map(i => 
                    i.id === item.id ? {...i, row} : i
                ));
                break;
            }
        }
        setDraggingItem(null);
    };

    return (
        <div className="flex prevent-select flex-row justify-between bg-white shadow-md p-4 gap-4">
            {[1,2,3].map(row => (
                <Drapable 
                    key={row}
                    items={data} 
                    row={row}
                    containerRef={(el) => el && containerRefs.current.set(row, el)}
                    onItemDragStart={(item) => setDraggingItem(item)}
                />
            ))}
        </div>
    )
}

interface IDrapableProps {
    items: IItem[];
    row: number;
    containerRef: (el: HTMLDivElement | null) => void; // 新增:传递容器ref给父组件
    onItemDragStart: (item: IItem) => void; // 新增:通知父组件开始拖拽的item
}

const Drapable = (props: IDrapableProps) => {
    return (
        <div className="flex flex-col bg-white rounded-md p-2 shadow-md gap-2 border border-primary w-full h-[500px]">
            <h2 className="text-2xl font-bold text-center text-secondary">row-{props.row}</h2>
            <div 
                className="flex gap-2 flex-col w-full h-full p-2"
                ref={props.containerRef} // 绑定容器ref
            >
                {props.items.map((item) => {
                    if (item.row === props.row) return (
                        <Draggable 
                            key={item.id} // 用唯一ID做key
                            item={item}
                            onDragEnd={(pos) => handleDragEnd(pos, item)}
                        >
                            <p>{item.data}</p>
                        </Draggable>
                    )
                    return null;
                })}
            </div>
        </div>
    )
}

export default MyTasks;
关键改动说明
  1. 状态与通信优化:

    • 给每个IItem添加唯一id,避免用数组index作为key导致的渲染问题
    • 在MyTasks中维护draggingItem状态,记录当前正在拖拽的元素
    • 通过回调函数在Draggable和Drapable、MyTasks之间传递拖拽状态和位置信息
  2. 拖拽逻辑修复:

    • 修复了鼠标/触摸移动时的位移计算问题(之前的逻辑会导致位移跳变)
    • 触摸事件中添加e.preventDefault(),阻止页面滚动干扰拖拽
    • 拖拽时给元素添加z-index提升层级,pointerEvents: none避免自身遮挡落点判断
  3. 手动模拟Drop逻辑:

    • 给每个Drapable容器绑定ref,存储到containerRefs中
    • 在拖拽结束时,通过getBoundingClientRect()获取容器的位置和尺寸,判断落点是否在容器内
    • 如果落点在目标容器,更新对应item的row属性,实现元素重排

内容的提问来源于stack exchange,提问作者noone

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 16:34:55