文档/指南

浮层

拖拽时跟随指针的东西——默认克隆及其样式、随拖拽变化的浮层组件、同时显示多个项、它的尺寸,以及它渲染在哪里。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

拖拽时,一个盒子跟随指针:这就是浮层。被拖拽的元素本身留在原处,作为它将落入的位置。放置时,浮层飞到那个位置然后消失。

浮层被传送到 body,位于你的组件之外。默认情况下,它显示被拖拽元素的克隆。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Expenses from './Expenses.vue';
</script>

<template>
  <DnDProvider>
    <Expenses />
  </DnDProvider>
</template>

默认克隆

克隆在拿起时生成,此时还没有任何东西移动。它是元素的深拷贝:

  • 表单的值和 <canvas> 的像素会被复制。
  • 元素离开列表后会丢失的计算样式会被内联:继承的字体和颜色、通过祖先匹配的样式(.list li { … })、表格单元格的宽度。表格行会被包在一个它自己的表格里。
  • id 和 data-dnd-* 属性会被去掉,克隆带有 aria-hidden。
  • UI 库(Vuetify、Quasar、PrimeVue、Naive UI)的涟漪和其他按下效果会被略去。

在上面的表格中,你输入过内容的行会带着它的文本、金额、勾选状态和图表一起移动,放在一个它自己的表格里,单元格保持真实单元格的宽度。

其他不应随之移动的东西,用 data-dnd-no-clone 标记:菜单按钮、提示框、选中标记。每一行的删除按钮就留在原处:

ExpenseRow.vue
<tr ref="el" class="row">
  …
  <td><button type="button" aria-label="Remove" data-dnd-no-clone @click="emit('remove')">×</button></td>
</tr>

不会猜测背景

克隆按元素本来的样子显示。自身没有背景、由列表绘制背景的行(白色 <ul> 中的 <li>、v-list-item、表格行),会得到一个透明的克隆。在 CSS 中给它加上列表的背景,这里加在单元格上:

ExpenseRow.vue
<style scoped>
[data-dnd-overlay] .row td {
  background: color-mix(in srgb, currentColor 4%, Canvas);
}
</style>

克隆保留元素的 class 和属性,包括 Vue 的作用域属性,因此这条规则写在行的 scoped 样式中和写在全局样式表中都有效。另一种办法是给行本身设置背景。本就应当悬浮的图标和图块则有意保持透明。

为盒子设置样式

盒子是带有 data-dnd-overlay 的元素。它是 position: fixed,位于最上层(z-index: 9999),pointer-events: none 且 inert,它的值表示它正在做什么:

data-dnd-overlay时机
"dragging"正在跟随指针。
"dropping"正在落到它的位置上、飞回原处,或在等待异步的 onBeforeDrop。

拖拽时呈现抬起的样子,落位时消失:

[data-dnd-overlay='dragging'] .row td {
  box-shadow: 0 14px 20px -16px rgb(0 0 0 / 0.7);
}

盒子通过 transform 移动,尺寸由 width 和 height 设置:为盒子里面的内容设置样式,而不是盒子本身。克隆填满盒子,它的根元素上设置了 width: 100%、height: 100% 和 transform: none:用 rotate 和 scale 属性旋转和缩放它,过渡和关键帧都能为这两个属性添加动画。具体做法见动画。

浮层组件

要显示克隆以外的东西,传入一个组件。它在盒子内部渲染,并以 props 的形式获得拖拽信息,这些 props 会在拖拽过程中变化:拖拽在哪里、那里是否接收它、放置是否为复制,以及放置本身。

Live

正在加载演示…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';

const columns = ref([
  {
    id: 'Backlog',
    tickets: [
      { id: 't1', title: 'Dark mode toggle', tag: 'UI', approved: false },
      { id: 't2', title: 'Retry failed uploads', tag: 'API', approved: true },
      { id: 't3', title: 'Keyboard shortcuts', tag: 'UI', approved: false },
    ],
  },
  { id: 'Doing', tickets: [{ id: 't4', title: 'Faster search', tag: 'API', approved: true }] },
  { id: 'Done', tickets: [{ id: 't5', title: 'Onboarding tour', tag: 'UI', approved: true }] },
]);
</script>

<template>
  <DnDProvider>
    <div class="board">
      <Column v-for="column in columns" :key="column.id" :column="column" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.board {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
  width: 100%;
  max-width: 600px;
  margin: 0 auto;
}
</style>
TicketOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DnDStatus, DragItem, DragMode, Over } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; over: Over | null; accepted: boolean; status: DnDStatus; mode: DragMode }>();
const ticket = computed(() => props.items[0]?.item as Ticket);

const state = computed(() => {
  if (props.status === 'dropping') return props.over ? 'landed' : 'back'; // a cancel flies back
  if (!props.over) return 'free';
  return props.accepted ? 'ok' : 'refused';
});
</script>

<template>
  <!-- Fills the overlay box, which takes the size of the slot. -->
  <div class="ticket-overlay" :data-state="state">
    {{ ticket.title }}
    <span v-if="mode === 'copy'" class="copy">+ copy</span>
  </div>
</template>

<style scoped>
.ticket-overlay { width: 100%; height: 100%; }
.ticket-overlay[data-state='refused'] { background: color-mix(in srgb, #ef4444 8%, Canvas); }
</style>
Prop内容
active拖拽(id、ids、data、type……)。
items所有被拖拽的项,抓起的那个在最前:{ id, item, data, type }。对于可排序拖拽,item 是列表项。
over拖拽下方的目标;放置落位期间,是它落到的那个目标。下方没有目标时,以及被拒绝或取消的拖拽飞回原处时为 null。可排序列表的 over.id 是列表的 id:上面的各列用的是它们的名称。
acceptedover 是否接收该拖拽:不接收时把浮层染成红色。
status'dragging',落位时变为 'dropping'。
mode'move'、'copy'(从带有 clone 的列表拖拽时按住 Alt,或从面板拖拽)、'swap',或 'insert'(普通可拖拽元素进入列表)。
to现在放置会把这些项放到哪里:{ list, index },在树中还有 parentKey 和 depth。不会把它们放进任何列表时为 null,例如在普通目标上方。见放置会去哪里。

这些 props 的类型是 OverlayProps;只声明你读取的那些。把组件传给被拖拽的东西:

import TicketOverlay from './TicketOverlay.vue';

makeSortableList(listRef, { items: tickets, overlay: TicketOverlay });   // every item of the list
makeSortableItem(el, { item: () => props.ticket, overlay: TicketOverlay }); // this item
makeDraggable(el, { id: 'chip', overlay: TicketOverlay });                // a plain draggable

组件可以直接传入,可以放在 shallowRef 中以便切换(拖拽过程中也可以:浮层会立即改变),也可以传入一个函数,Vue 会把它当作函数式组件:在 TSX 中写作 overlay: (props) => <TicketOverlay {...props} compact />。永远不要把它放进深层的 ref 或 reactive:Vue 会把组件变成响应式的并发出警告。

浮层组件的根元素应当填满盒子(width: 100%; height: 100%):盒子取位置的尺寸,并在放置时为它添加动画。根元素上的固定尺寸则会在盒子内部保持该尺寸。

provider 的 #overlay 插槽

对于每个没有自己组件的拖拽,由 provider 的插槽渲染浮层。在应用的根组件旁定义一次,应用中的每个列表和可拖拽元素都会用上它。这里便签有自己的虚影,照片则回退到克隆:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Moodboard from './Moodboard.vue';
import NoteGhost from './NoteGhost.vue';
</script>

<template>
  <DnDProvider>
    <Moodboard />
    <!--
      The overlay of every drag in the app that has none of its own. Notes get a ghost
      of their own; for photos the slot renders nothing, so they fall back to the clone.
    -->
    <template #overlay="{ items, status }">
      <NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
    </template>
  </DnDProvider>
</template>
App.vue
<DnDProvider>
  <RouterView />
  <template #overlay="{ items, status }">
    <NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
  </template>
</DnDProvider>

插槽获得与浮层组件相同的 props。当它什么都不渲染时(上面的 v-if),拖拽回退到克隆。按 type 区分不同的拖拽:可排序项的类型是它所在分组的名称,除非它设置了自己的类型(makeSortableItem(el, { type }))。

改变浮层的目标

放置目标可以决定,当它接受的拖拽位于它上方时浮层显示什么。makeDroppable、列表和树都接受 overOverlay(列表或树的 overlay 是给它自己的项用的)。在文件夹上方,被拖拽邮件的标签会变成这次放置将要做的事:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Mailbox from './Mailbox.vue';
</script>

<template>
  <!-- The mailbox reads the selection with `useDnD()`: it renders inside the provider. -->
  <DnDProvider>
    <Mailbox />
  </DnDProvider>
</template>
Folder.vue
makeDroppable(el, {
  id: () => props.folder.id,
  data: () => props.folder,
  overOverlay: MovePill,
  onDrop: (event) => emit('move', event.active.ids),
});

目标的组件获得与任何浮层组件相同的 props,其中包括 over,并把拖拽自己的浮层(它的组件、provider 的插槽或克隆)作为默认插槽。MovePill 省略了插槽,所以它会替换标签:

MovePill.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, Over } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; over: Over | null }>();
// The folder under the drag: its `data`.
const folder = computed(() => props.over?.data as Folder | undefined);
</script>

<template>
  <!-- Fills the box like any overlay component. -->
  <div class="pill-box">
    <span v-if="folder" class="pill">Move {{ items.length }} to {{ folder.name }}</span>
  </div>
</template>

渲染 <slot /> 可以保留拖拽自己的浮层并包裹它。上面浮层组件的看板中,Done 就是这样做的:在 Done 上方,已批准的工单保留它自己的浮层,Done 给它盖章,来自其他列的工单在放下时会迎来彩纸屑。

Column.vue
makeSortableList(listRef, {
  items: tickets,
  group: { name: 'release', put },
  overlay: TicketOverlay, // the tickets' own, in every column
  overOverlay: strict ? DoneOverlay : undefined, // Done's, around it
});
DoneOverlay.vue
<template>
  <div class="done">
    <slot /> <!-- TicketOverlay -->
    <span class="stamp">Shipped</span>
    <span v-if="status === 'dropping' && arrives" class="confetti">…</span>
  </div>
</template>
  • 它只在目标接受该拖拽时显示,并在放置落到这个目标上时保持显示。离开目标后,拖拽自己的浮层就会回来。
  • 切换时会交叉淡入淡出。provider 的 overlayTransition 决定怎么切换:false 立即替换,对象则是包裹内容的 <Transition> 的 props(name、mode,以及用于 GSAP 或 motion 的 JS 钩子)。没有 mode: 'out-in' 时,两者会同时显示片刻:在 .<name>-leave-active 中给离开的那个设置 position: absolute。
  • 放置会等待彩纸屑:浮层落位时在其中运行的 CSS 动画和过渡会播放到结束,最多两秒。
  • 带有 overlay: false 的拖拽在任何目标上方都没有浮层。

文件夹还会以自己的方式让胶囊落位,落进文件夹的图标:目标也可以有 overDropAnimation。见动画。

放置会去哪里:to

to 告诉浮层现在放置会把这些项放到哪里,与放置事件的 to 将给出的一致:列表和其中的索引,在树中还有父节点和深度。它随位置的移动而变化。这个网站菜单中的页面会写出它将进入哪个栏目,以及在第几行:

Live

正在加载演示…

<script setup lang="ts">
import { provide, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import MenuTree from './MenuTree.vue';
import { MENU, site } from './menu';

// Provided above the provider: its overlay renders under it, so the overlay sees it too.
provide(MENU, ref(site()));
</script>

<template>
  <DnDProvider>
    <MenuTree />
  </DnDProvider>
</template>
MoveOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, DropDestination } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; to: DropDestination | null }>();
const page = computed(() => props.items[0]!.item as MenuNode);
// `parentKey` is the section the page would go into, `null` at the top level.
const where = computed(() => (props.to ? pathOf(menu.value, props.to.parentKey) : null));
</script>

<template>
  <div class="move">
    <div class="row">{{ page.title }}</div>
    <p v-if="to && where" class="where">Move to <b>{{ where }}</b>, row {{ to.index + 1 }}</p>
  </div>
</template>

在所有目标之外,to 仍是空位所在的地方:默认情况下,在那里放置会把这些项留在空位处(dropOutside: 'placeholder')。当放置不会把它们放进任何列表时为 null:在普通目标上方、在拒绝它们的列表上方、在 dropOutside: 'cancel' 或 'remove' 时位于目标之外。

谁优先

对于每次拖拽,取下面第一个适用的:

  1. 放置目标的 overOverlay(列表或树的也一样),在拖拽位于它上方且它接受该拖拽时。原本会显示的东西成为它的默认插槽。
  2. 项自己的 overlay(makeSortableItem、makeDraggable)。
  3. 它所在列表或树的 overlay。
  4. provider 的 #overlay 插槽,除非它什么都不渲染。
  5. 默认克隆。在多项拖拽中,是一列或一叠克隆:见多项拖拽。

拖拽究竟有没有浮层(overlay: false)在拖拽开始时决定。显示哪个组件则是实时读取的:在拖拽过程中切换 ref 会替换它,进入或离开带有自己浮层的目标也会。

DragPreview:布局中的克隆

DragPreview 渲染库为某个被拖拽项生成的克隆。浮层组件可以排布多个克隆,或给一个克隆加上外框。点击书本来选中它们,然后拖动其中一本:浮层会把真实的克隆扇形展开。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Shelf from './Shelf.vue';
</script>

<template>
  <DnDProvider>
    <Shelf />
  </DnDProvider>
</template>
FanOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import { DragPreview, type DragItem } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[] }>();
const fan = computed(() =>
  props.items.slice(0, 4).map((entry, index) => ({
    entry,
    angle: index === 0 ? 0 : (index % 2 ? -1 : 1) * Math.ceil(index / 2) * 10,
  })),
);
</script>

<template>
  <div class="fan">
    <div v-for="{ entry, angle } in fan" :key="entry.id" class="card" :style="{ transform: `rotate(${angle}deg)` }">
      <DragPreview :id="entry.id" class="face" :style="{ width: '100%', height: '100%' }">
        <div class="blank" /> <!-- an item without a clone -->
      </DragPreview>
    </div>
    <span v-if="items.length > 1" class="count">{{ items.length }}</span>
  </div>
</template>
  • 克隆针对抓起的项以及按放置顺序紧邻它的其他项生成,数量为 provider 的 overlayPreviews(默认 5 个,'all' 表示每一个),前提是它们在拿起时已被渲染。对于其他任何项,DragPreview 渲染它的默认插槽。
  • 预览具有其项在拿起时的尺寸,以内联方式设置。用 style 设置它的尺寸,style 会透传下去(class 无法覆盖它),如上所示。
  • 在可排序的多项拖拽中(multiple: 'group' 或 'gaps'),放置时每个预览都会落到它自己那一项的位置上。用 transform(而不是 rotate 属性)旋转它的包裹元素:落位时会在途中撤销旋转。把项的外观(圆角、阴影)放在预览本身上,就像 class="face" 那样:包裹元素留在盒子里。

多项拖拽指南中有一个浮层,显示的是数量和标题而不是克隆。

尺寸:overlayFit

在尺寸不同的列表上方时,盒子取它将落入的位置的尺寸。provider 的 overlayFit 可以切换这一点;DnDProvider 页面有一个带窄列表和宽列表的演示。

overlayFit(provider)盒子
'slot'(默认)取它将落入的位置的尺寸,平滑过渡过去。在较宽的列表上方时,它会变宽。
'source'保持被拖拽元素的尺寸。

使用 'slot' 时,盒子围绕指针改变尺寸,指针始终位于盒子的同一比例处。内容永远不会被缩放:它会按新尺寸重新布局,所以像那个演示中的任务那样的 flex 行在任何宽度下都显示正常。如果位置的样式比项小得多,比如一条细线,盒子不会沿那个轴缩小。

无论哪种设置,放置时都一样:盒子飞向落位元素并以动画过渡到它的尺寸,内容在途中重新布局。当一个项在两个列表中外观不同时(面板中的紧凑标签、看板上的完整卡片),源元素的克隆会按目标的尺寸重新布局,在那里可能显得被挤压或拉伸。有两种办法保持正常:

  • 给项一个在两种尺寸下都适用的布局,就像任务行那样,或者让它在所有可能落入的列表中尺寸相同。
  • 在浮层组件中渲染目标的外观。它会收到 over,因此可以切换到它所在列表的外观,并在任何尺寸下填满盒子。

渲染位置:overlayTo

provider 把浮层传送到 body。模态 <dialog>(用 showModal() 打开)位于顶层,在 body 中所有内容之上,所以放在那里的浮层会被它遮住。把 overlayTo 指向对话框内部的一个选择器或元素。DnDProvider 页面有一个对话框,可以试试用和不用它的区别:

index.vue
<script setup lang="ts">
const dialog = useTemplateRef<HTMLDialogElement>('dialog');
const open = ref(false);
// While the dialog is open, the overlay renders inside it.
const overlayTo = computed(() => (open.value ? '#columns-dialog' : 'body'));

const show = () => {
  dialog.value?.showModal();
  open.value = true;
};
</script>

<template>
  <DnDProvider :overlay-to="overlayTo">
    <button type="button" @click="show">Edit columns</button>
    <dialog id="columns-dialog" ref="dialog" @close="open = false">
      <ColumnList v-model:columns="columns" />
    </dialog>
  </DnDProvider>
</template>
  • provider 渲染浮层时(在它挂载之后),目标必须已在页面中。关闭的 <dialog> 也在页面中:它只是被隐藏了。对话框关闭时切换回 body,这样其他拖拽的浮层才能显示。
  • 盒子是 position: fixed。目标或其祖先上的 transform、filter 或 contain 会让它代替视口成为盒子的参照框,所以不要在对话框上使用它们。
  • 克隆的样式是针对它显示的位置计算的:在 shadow root 中,把 overlayTo 指向其内部,克隆就会保留 shadow root 的样式。

不使用浮层

makeDraggable(el, { overlay: false }) 完全没有盒子:元素本身通过返回的 transform 移动。

const { transform } = makeDraggable(el, { overlay: false });
const style = computed(() =>
  transform.value ? { translate: `${transform.value.x}px ${transform.value.y}px` } : undefined,
);

见拖放。有盒子的拖拽如何落位由它的 dropAnimation 决定:放置目标的(overDropAnimation)、项的(或它所在列表的)或 provider 的。见动画。