Next.js项目中使用dnd-kit拖拽排序时,占位符释放元素后立即消失而非等待DragOverlay过渡完成的问题
解决dnd-kit拖拽释放时占位符过早消失的UI生硬跳转问题
这个问题我之前也踩过坑,核心原因是咱们在onDragEnd里立刻把activeId设为null,导致占位符瞬间消失,但DragOverlay的过渡动画还在执行,视觉上就出现了突兀的跳转。要实现你想要的流畅效果,关键是让占位符保持可见,直到DragOverlay的过渡动画完全结束。
下面是具体的修改方案,我会一步步解释逻辑:
1. 新增过渡状态跟踪
首先,我们需要一个状态来标记DragOverlay是否正在执行过渡动画:
const [isTransitioning, setIsTransitioning] = useState(false);
2. 调整拖拽结束/取消的逻辑
在handleDragEnd里,不要立刻清除activeId,而是先开启过渡状态(因为此时DragOverlay的动画刚启动):
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,先标记过渡开始 setIsTransitioning(true); }
同时,拖拽取消的场景不需要等待动画,直接清除所有状态:
onDragCancel={() => { setActiveId(null); setIsTransitioning(false); }}
3. 监听DragOverlay的过渡结束事件
给OverlayItem添加onTransitionEnd事件,当动画完成后再清理状态,让占位符消失:
function OverlayItem({ shelf, onTransitionEnd }: { shelf: Shelf; onTransitionEnd: () => void }) { return ( <div className="flex items-center gap-2 p-3 rounded-md border border-blue-400 bg-blue-50 shadow-lg w-80" onTransitionEnd={onTransitionEnd} > <span className="px-2 py-1 rounded bg-gray-200">☰</span> <span>{shelf.name}</span> </div> ); }
然后在使用OverlayItem时传入这个回调:
<DragOverlay> {activeShelf ? ( <OverlayItem shelf={activeShelf} onTransitionEnd={() => { // 过渡完成后清除状态,占位符随之消失 setActiveId(null); setIsTransitioning(false); }} /> ) : null} </DragOverlay>
4. 保持占位符的显示逻辑
原来的isPlaceholder判断不需要修改,因为activeId会一直保留到过渡结束,所以占位符会持续显示在被拖拽元素的目标位置:
<SortableItem key={shelf.id} shelf={shelf} selectedShelfs={selectedShelfs} setSelectedShelfs={setSelectedShelfs} selectedShelfId={selectedShelfId} setSelectedShelfId={setSelectedShelfId} isPlaceholder={activeId === shelf.id} />
修改后的完整代码
"use client"; import React, { useState } 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> ); } function OverlayItem({ shelf, onTransitionEnd }: { shelf: Shelf; onTransitionEnd: () => void }) { return ( <div className="flex items-center gap-2 p-3 rounded-md border border-blue-400 bg-blue-50 shadow-lg w-80" onTransitionEnd={onTransitionEnd} > <span className="px-2 py-1 rounded bg-gray-200">☰</span> <span>{shelf.name}</span> </div> ); } 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 sensors = useSensors(useSensor(PointerSensor)); 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={() => { setActiveId(null); setIsTransitioning(false); }} > <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 shelf={activeShelf} onTransitionEnd={() => { setActiveId(null); setIsTransitioning(false); }} /> ) : null} </DragOverlay> </DndContext> </div> ); }
逻辑说明
- 延迟清除
activeId:拖拽结束后,activeId会保留到过渡动画完成,所以占位符会一直显示在被拖拽元素的目标位置(因为我们已经更新了shelves的顺序)。 - 过渡结束后清理状态:当DragOverlay的动画完全结束,再清除
activeId,此时占位符刚好和DragOverlay的位置重合,消失时不会有视觉跳转。 - 兼容取消拖拽场景:如果用户中途取消拖拽,直接清除所有状态,避免不必要的等待。
这样修改后,就能实现你想要的流畅效果:拖拽时显示占位符,释放后DragOverlay平滑过渡到目标位置,动画完成后占位符自然消失。
内容的提问来源于stack exchange,提问作者user32470124
相关产品推荐
相关产品推荐

