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

Next.js项目中使用dnd-kit实现侧边栏拖拽排序:Placeholder在释放元素时立即消失而非等待DragOverlay过渡完成的问题

解决dnd-kit拖拽释放时Placeholder生硬消失的问题

我明白你遇到的问题——拖拽释放瞬间Placeholder立刻消失,而DragOverlay还在执行归位动画,导致UI跳变。核心原因是你在handleDragEnd里立刻清空了activeId,让Placeholder提前消失了。我们要做的就是让Placeholder等到DragOverlay的动画完全结束后再消失,下面是具体的解决方案:

核心思路

  1. 不再在拖拽结束时立刻清空activeId,而是先标记"过渡中"状态
  2. 监听DragOverlay的动画结束事件,等动画完成后再清空activeId、隐藏Placeholder
  3. 确保拖拽取消的场景也遵循同样的逻辑

具体修改步骤

1. 添加过渡状态与Ref

在Home组件中新增状态跟踪过渡状态,同时给DragOverlay元素添加Ref用于监听动画:

const [isTransitioning, setIsTransitioning] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);

2. 监听DragOverlay的动画结束事件

通过transitionend事件精准捕捉动画完成时机,此时再重置状态:

useEffect(() => {
  const handleTransitionEnd = () => {
    if (isTransitioning) {
      // 动画结束后才清空activeId,让Placeholder消失
      setActiveId(null);
      setIsTransitioning(false);
    }
  };

  const element = overlayRef.current;
  if (element) {
    element.addEventListener('transitionend', handleTransitionEnd);
  }

  // 组件卸载时清理监听
  return () => {
    if (element) {
      element.removeEventListener('transitionend', handleTransitionEnd);
    }
  };
}, [isTransitioning]);

3. 修改拖拽结束与取消的逻辑

不再直接清空activeId,而是先标记过渡状态:

function handleDragEnd(event: any) {
  const { active, over } = event;

  if (over && active.id !== over.id) {
    const oldIndex = shelves.findIndex((s) => s.id === active.id);
    const newIndex = shelves.findIndex((s) => s.id === over.id);
    setShelves((items) => arrayMove(items, oldIndex, newIndex));
  }
  // 标记开始过渡,保留activeId让Placeholder继续显示
  setIsTransitioning(true);
}

// 拖拽取消时也执行同样的过渡逻辑
onDragCancel={() => setIsTransitioning(true)}

4. 重构OverlayItem组件传递Ref

使用forwardRef让OverlayItem能接收并绑定Ref:

const OverlayItem = forwardRef<HTMLDivElement, { shelf: Shelf }>(({ shelf }, ref) => {
  return (
    <div 
      ref={ref} 
      className="flex items-center gap-2 p-3 rounded-md border border-blue-400 bg-blue-50 shadow-lg w-80"
    >
      <span className="px-2 py-1 rounded bg-gray-200">☰</span>
      <span>{shelf.name}</span>
    </div>
  );
});

OverlayItem.displayName = "OverlayItem";

5. 更新DragOverlay的使用

将Ref传递给OverlayItem:

<DragOverlay>
  {activeShelf ? <OverlayItem ref={overlayRef} shelf={activeShelf} /> : null}
</DragOverlay>

修改后的完整代码

"use client";
import React, { useState, useRef, useEffect, forwardRef } from "react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, } from "@dnd-kit/core";
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy, } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

type Shelf = {
  id: number;
  name: string;
};

const initialShelves: Shelf[] = [
  { id: 1, name: "Shelf A" },
  { id: 2, name: "Shelf B" },
  { id: 3, name: "Shelf C" },
];

function SortableItem({ shelf, selectedShelfs, setSelectedShelfs, selectedShelfId, setSelectedShelfId, isPlaceholder, }: any) {
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: shelf.id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
  return (
    <li ref={setNodeRef} style={style}>
      {isPlaceholder ? (
        <div className="p-3 mb-2 rounded-md bg-red-600 border border-red-700 h-[44px]" />
      ) : (
        <div className={` flex items-center gap-2 p-3 mb-2 rounded-md border text-black bg-white ${selectedShelfId === shelf.id ? "border-blue-500 border-2" : "border-gray-300"} `} >
          <span {...attributes} {...listeners} className="cursor-grab select-none px-2 py-1 rounded bg-gray-200" >
            ☰
          </span>
          <input
            type="checkbox"
            checked={selectedShelfs.includes(shelf.id)}
            onChange={() =>
              setSelectedShelfs((prev: number[]) =>
                prev.includes(shelf.id)
                  ? prev.filter((id) => id !== shelf.id)
                  : [...prev, shelf.id],
              )
            }
          />
          <div className="flex-1 cursor-pointer" onClick={() => setSelectedShelfId(shelf.id)} >
            {shelf.name}
          </div>
        </div>
      )}
    </li>
  );
}

const OverlayItem = forwardRef<HTMLDivElement, { shelf: Shelf }>(({ shelf }, ref) => {
  return (
    <div 
      ref={ref} 
      className="flex items-center gap-2 p-3 rounded-md border border-blue-400 bg-blue-50 shadow-lg w-80"
    >
      <span className="px-2 py-1 rounded bg-gray-200">☰</span>
      <span>{shelf.name}</span>
    </div>
  );
});

OverlayItem.displayName = "OverlayItem";

export default function Home() {
  const [shelves, setShelves] = useState(initialShelves);
  const [selectedShelfs, setSelectedShelfs] = useState<number[]>([]);
  const [selectedShelfId, setSelectedShelfId] = useState<number | null>(null);
  const [activeId, setActiveId] = useState<number | null>(null);
  const [isTransitioning, setIsTransitioning] = useState(false);
  const overlayRef = useRef<HTMLDivElement>(null);

  const sensors = useSensors(useSensor(PointerSensor));

  useEffect(() => {
    const handleTransitionEnd = () => {
      if (isTransitioning) {
        setActiveId(null);
        setIsTransitioning(false);
      }
    };

    const element = overlayRef.current;
    if (element) {
      element.addEventListener('transitionend', handleTransitionEnd);
    }

    return () => {
      if (element) {
        element.removeEventListener('transitionend', handleTransitionEnd);
      }
    };
  }, [isTransitioning]);

  function handleDragEnd(event: any) {
    const { active, over } = event;

    if (over && active.id !== over.id) {
      const oldIndex = shelves.findIndex((s) => s.id === active.id);
      const newIndex = shelves.findIndex((s) => s.id === over.id);
      setShelves((items) => arrayMove(items, oldIndex, newIndex));
    }
    setIsTransitioning(true);
  }

  const activeShelf = shelves.find((s) => s.id === activeId);

  return (
    <div className="max-w-md mx-auto mt-10 font-sans">
      <h3 className="mb-4 text-lg font-semibold">Shelves</h3>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={(e) => setActiveId(e.active.id as number)}
        onDragEnd={handleDragEnd}
        onDragCancel={() => setIsTransitioning(true)}
      >
        <SortableContext items={shelves.map((s) => s.id)} strategy={verticalListSortingStrategy} >
          <ul className="list-none p-0">
            {shelves.map((shelf) => (
              <SortableItem
                key={shelf.id}
                shelf={shelf}
                selectedShelfs={selectedShelfs}
                setSelectedShelfs={setSelectedShelfs}
                selectedShelfId={selectedShelfId}
                setSelectedShelfId={setSelectedShelfId}
                isPlaceholder={activeId === shelf.id}
              />
            ))}
          </ul>
        </SortableContext>
        <DragOverlay>
          {activeShelf ? <OverlayItem ref={overlayRef} shelf={activeShelf} /> : null}
        </DragOverlay>
      </DndContext>
    </div>
  );
}

效果说明

修改后,拖拽释放的流程会变成:

  1. 拖拽元素 → 列表中出现Placeholder
  2. 释放元素 → DragOverlay开始执行归位动画,Placeholder保持可见
  3. 动画完全结束 → Placeholder消失,UI过渡自然无跳转

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.28 06:39:52