在拖放项目时,占位符会立即消失,而不是等待覆盖层的过渡动画

前端开发 2026-07-11

我在一个 Next.js 项目中使用 dnd-kit 来在侧边栏重新排序项目。拖动工作正常,但在释放时存在一个视觉问题。

在拖动过程中,我在列表中渲染一个 占位符元素isPlaceholder),同时通过一个 DragOverlay 显示被拖拽的项。

释放时会出现问题:

  • 一旦我松手,占位符会立即消失。
  • 同时,DragOverlay 会回到列表中的位置并进行动画。
  • 由于占位符被立即移除,界面出现了明显的硬切换

我更希望的是:

  • 占位符应该在Overlay完成其过渡之前保持可见
  • 换句话说,覆盖层应在占位符消失之前,视觉上落在其上方。

当前行为:

  1. 拖动项 → 占位符出现在列表中
  2. 松手 → 占位符会立即消失
  3. 覆盖层回到位置的动画 → 视觉跳跃

期望行为:

  1. 拖动项 → 占位符出现
  2. 松手 → 覆盖层向后回到原位并完成动画
  3. 占位符在Overlay完成过渡后才消失

下面是渲染每个可排序项的组件。拖动把手通过 dragHandleProps 传递给子组件。

"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 }: { shelf: Shelf }) {
    return (
        <div 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>
    );
}

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 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));
        }

        setActiveId(null);
    }

    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)}
            >
                <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} /> : null}
                </DragOverlay>
            </DndContext>
        </div>
    );
}

示例:

解决方案

视觉跳跃之所以发生,是因为 DndContext 在松手时会立即触发 onDragEnd,在 DragOverlay 完成过渡之前就清除了你的 activeId

为修复此问题,请将 setActiveId(null) 移到 DragOverlay's onDragEnd 属性上,这会等待动画完成后才触发。

function handleDragEnd(event) {
  const { active, over } = event;
  if (over && active.id !== over.id) {
    setShelves((items) => arrayMove(items, oldIndex, newIndex));
  }
  // Remove setActiveId(null) from here
}
<DndContext onDragEnd={handleDragEnd}>
  <SortableContext items={items}>
    {items.map(item => (
      <SortableItem isPlaceholder={activeId === item.id} />
    ))}
  </SortableContext>

  <DragOverlay onDragEnd={() => setActiveId(null)}>
    {activeItem ? <Item /> : null}
  </DragOverlay>
</DndContext>

DndContext 会立即重新排序列表,将占位符移动到目标位置。DragOverlay 向那个占位符进行动画靠拢。DragOverlay.onDragEnd 只有在真实项落地后才将占位符替换为实际项。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章