文档/指南

轴、边界与网格

把拖拽锁定在一个轴上、限制在一个盒子内、吸附到网格或你自己的参考线——可用于单个项、一个列表或页面的整个部分。

编辑此页

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

拖拽可以被固定在一个轴上、限制在一个盒子内、吸附到网格,或按你自己的规则调整。这些规则就是修饰器:它们在每一帧调整拖拽的偏移。可以把它们设置在一个可拖拽元素、一个列表或页面的一部分上,浮层、放置目标和放置都会遵循它们。

锁定轴

时间线上的片段沿着它们的轨道滑动。关闭锁定后,它们也可以换到别的轨道。

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Timeline />
  </DnDProvider>
</template>
TimelineClip.vue
const { isDragging } = makeDraggable(el, {
  id: () => props.clip.id,
  axis: () => (props.locked ? 'x' : undefined),
});

axis: 'x' 只沿水平方向移动,'y' 只沿竖直方向移动。makeDraggable、makeSortableList、makeSortableTree、makeSortableItem 和 makeDragArea 都接受它:

makeSortableList(listRef, { items: tracks, axis: 'y' });
  • 锁定在所有修饰器之后运行,所以无论它们做什么,锁定都成立:吸附或边界无法把项推离它的直线。
  • 这条直线在内容里,而不在屏幕上:它随拖拽开始时所在的列表(或父元素)移动。拖拽中途滚动页面,项仍留在它的那一行。
  • 项自己的 axis 优先于它所在列表或树的,可拖拽元素(或它所在列表)的又优先于它所在拖拽区域的。
  • 它在拖拽开始时读取,所以可以像上面那样用 getter 切换它。
  • 锁定到 'y' 的树仍然可以嵌套:此时深度来自悬停在某一行的中间。
优先使用 axis,而不是 restrictToVerticalAxis 和 restrictToHorizontalAxis 修饰器。它们做的是同一件事,但作为普通修饰器,它们在链条中间运行,排在它们之后的修饰器(同时吸附两个轴的网格)可能会再次把项移离该轴。

修饰器

修饰器在每一帧调整拖拽的偏移。它的结果既移动浮层,也是查找目标所用的点,因此列表按锁定、限制或吸附后的位置排序,放置的 position 就是盒子最终所在的位置。

修饰器作用
restrictToParent把浮层限制在拖拽开始时所在的容器内:可排序列表,或普通可拖拽元素的父元素。
restrictToElement(target)把浮层限制在一个元素内:模板引用、getter 或元素本身。
restrictToWindowEdges把浮层限制在窗口内。
restrictToFirstScrollableAncestor把浮层限制在被拖拽元素的第一个可滚动祖先内。
snapToGrid(size)把浮层吸附到网格(见下文)。
snapCenterToCursor让浮层居中于指针下方,而不是保持抓取时的偏移。
restrictToVerticalAxis只沿竖直方向移动。优先使用 axis: 'y'。
restrictToHorizontalAxis只沿水平方向移动。优先使用 axis: 'x'。

modifiers 是 provider、拖拽区域、列表和树,以及可拖拽元素和可排序项的选项。除 provider 的以外,其余的都还接受 ref 或 getter,在拖拽开始时读取。

限制在内部

视频通话中的自己画面始终留在屏幕上。切换边界,然后把它远远拖出去:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <CallScreen />
  </DnDProvider>
</template>
SelfView.vue
const { isDragging } = makeDraggable(el, {
  id: 'self-view',
  modifiers: () => props.modifiers, // [restrictToParent], [restrictToWindowEdges] or []
});
  • restrictToParent 把它固定在屏幕上,也就是它的父元素。对于可排序项,“父元素”是它所在的列表,无论列表位于 DOM 中的哪里。
  • restrictToWindowEdges 允许它离开屏幕,但不能离开窗口。放在屏幕之外时,它不在放置目标上,所以会飞回原处。
  • 没有边界时,它可以去任何地方。

边界以浮层当前的尺寸把它限制在内部:使用 overlayFit: 'slot' 时,在较宽项的列表上方变大的盒子会以新的尺寸被限制在内部。

吸附到网格

流程图的节点吸附到画布网格上。切换单元格大小和平滑滑动:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Flowchart />
  </DnDProvider>
</template>
FlowNode.vue
const { isDragging } = makeDraggable(el, {
  id: () => props.node.id,
  modifiers: () => [snapToGrid({ x: props.grid, y: props.grid, smooth: props.smooth })],
});

snapToGrid(24) 把两个轴都吸附到 24 px 的单元格。传入对象可以设置更多:

选项含义
x、y每个轴的单元格大小,单位为像素。省略其中一个,就只吸附另一个轴:{ y: 15 } 把事件吸附到每刻钟,同时让它在各天之间自由移动。
origin'container'(默认):浮层的左上角落在从容器左上角画出的网格线上——容器是可排序列表,或普通可拖拽元素的父元素。'delta':对偏移本身取整,所以一开始就不在网格上的项会一直不在网格上,并从原来的位置按整格移动。适用于放在任意位置(自由画布)、需要保持自身对齐的项。
smoothtrue 或 { duration, easing }:浮层在单元格之间平滑滑动(默认 80 ms)。目标立即使用吸附后的位置。

使用 origin: 'container' 时,从容器的 border box 开始画网格:上面的画布用 box-shadow 而不是 border 作为边框,所以它的网格线从最边缘开始。

自定义修饰器

幻灯片上的方框靠近其他方框时,会跳到它们的边缘和中心上,粉色参考线显示它们所对齐的线:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Slide />
  </DnDProvider>
</template>
Slide.vue
import type { Modifier } from '@vue-dnd-kit/core';

/** Near a line, the box jumps onto it. */
const magnet: Modifier = ({ transform, overlayRect, containerRect, active }) => {
  if (!containerRect) return transform;
  const id = String(active.id);
  const left = overlayRect.left - containerRect.left;
  const top = overlayRect.top - containerRect.top;
  return {
    x: transform.x + pull(left, overlayRect.width, lines(id, 'x')),
    y: transform.y + pull(top, overlayRect.height, lines(id, 'y')),
  };
};

// In order: the magnet pulls, then the slide's edges hold.
const modifiers = [magnet, restrictToParent];

修饰器接收 transform,即经过前面的修饰器处理后、相对拖拽起点的偏移,并返回一个新的偏移。它还会收到计算时可能需要的信息:

参数是什么
transform到目前为止的偏移。
overlayRect按 transform 移动后的浮层盒子,尺寸为它当前的尺寸。
activeRect拖拽开始时的被拖拽元素。
containerRect拖拽开始时所在的容器(可排序列表或父元素)的当前位置:它会跟随滚动。
windowRect窗口。
origin拖拽开始时的指针。
active、over拖拽,以及它下方的目标。

修饰器在每一帧运行:让它们保持快速、没有副作用。上面的参考线是在监听器的 onDragMove 中根据拖拽的 delta 画出的,delta 是经过所有修饰器之后的偏移。类型见修饰器。

顺序

来自多个地方的修饰器依次运行,每一个都基于前一个的结果:

  1. provider 的 modifiers;
  2. 拖拽开始时所在的拖拽区域的 modifiers;
  3. 被拖拽项自己的 modifiers,没有时取它所在列表或树的;
  4. 拖拽区域的 restrictToArea;
  5. axis 锁定。

在同一个数组中,它们按顺序运行,所以把吸附和磁吸放在边界之前:放在最后的边界无论其他修饰器做了什么,都能把盒子留在内部。上面的幻灯片运行的是 [magnet, restrictToParent];如果顺序反过来,磁吸可能会把一个被固定在幻灯片边缘的方框拉出边缘几个像素。

项的 modifiers 会替换它所在列表的,而不是追加。区域的边界在第 1–3 步的吸附之后生效,所以吸附后的项仍然留在内部,而轴锁定在所有步骤的最后。

有边界的列表能到达两端

一个图层面板:行只能上下移动,永远不会离开面板。把最上面一行拖到最底部:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Layers />
  </DnDProvider>
</template>
Layers.vue
const { items } = makeSortableList(list, { items: layers, axis: 'y', modifiers: [restrictToParent] });

指针继续移动时,边界会把浮层固定在盒子的边缘。一旦指针沿列表轴向离开了浮层,列表就改为按指针排序,所以无论你怎样握住项,第一个和最后一个位置都始终可以到达。网格仍按浮层排序:固定在边缘时,它已经覆盖了边缘的单元格。

页面某一部分的规则

两个区域,两套规则。Layout 中的贴纸吸附到它的网格上,并且不能离开它;Sketch 中的贴纸可以去任何地方,也可以进入 Layout。拖拽保持它开始时所在区域的规则,规则正在生效的区域会高亮。

Live

正在加载演示…

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

const stickers = ref([
  { id: 'logo', text: 'Logo', color: '#42b883', area: 'sketch', x: 20, y: 24 },
  { id: 'photo', text: 'Photo', color: '#3b82f6', area: 'sketch', x: 110, y: 96 },
  { id: 'quote', text: 'Quote', color: '#ec4899', area: 'sketch', x: 36, y: 150 },
  { id: 'title', text: 'Title', color: '#f59e0b', area: 'layout', x: 24, y: 24 },
  { id: 'button', text: 'Button', color: '#a855f7', area: 'layout', x: 120, y: 144 },
]);

const place = (area: string, id: Key, at: Coordinates) => {
  const sticker = stickers.value.find((item) => item.id === id);
  if (!sticker) return;
  sticker.area = area;
  sticker.x = Math.round(at.x);
  sticker.y = Math.round(at.y);
};
</script>

<template>
  <DnDProvider>
    <div class="studio">
      <DragArea
        id="sketch"
        title="Sketch"
        rules="no rules"
        :stickers="stickers.filter((sticker) => sticker.area === 'sketch')"
        @place="place"
      />
      <DragArea
        id="layout"
        title="Layout"
        rules="snapToGrid(24) · restrictToArea"
        strict
        :stickers="stickers.filter((sticker) => sticker.area === 'layout')"
        @place="place"
      />
    </div>
  </DnDProvider>
</template>

<style scoped>
.studio {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 12px;
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
}
</style>
DragArea.vue
<script setup lang="ts">
const area = useTemplateRef<HTMLElement>('area');
const { isDragging } = makeDragArea(area, {
  modifiers: () => (props.strict ? [snapToGrid(24)] : []),
  restrictToArea: () => props.strict,
});
</script>

<template>
  <div ref="area" class="area" :class="{ busy: isDragging }">
    <Sticker v-for="sticker in stickers" :key="sticker.id" :sticker="sticker" />
  </div>
</template>

makeDragArea 为在其元素内部开始的拖拽设置规则——普通可拖拽元素和可排序项都一样,它们因此不再需要自己的规则。整个应用保留一个 DnDProvider,给画板、画布或信息流单独一个区域。

选项作用
axis把拖拽锁定到一个轴。可拖拽元素自己的 axis 或它所在列表的优先。
modifiers在 provider 的之后、可拖拽元素自己的之前运行。
restrictToAreatrue:拖拽留在区域的元素内。
autoScroll区域滚动容器(该元素,或它最近的可滚动祖先)的自动滚动。false 在那里关闭它。
activation按指针类型设置的激活,比如在滚动的信息流中使用更长的触摸按下。可拖拽元素自己的优先;provider 的最后。
disabled区域内以及其内部的区域中都不会开始拖拽。它的可拖拽元素会报告 aria-disabled。

每个选项都接受值、ref 或 getter,在按下或拖拽开始时读取。区域返回 isDragging(一个从这里开始的拖拽正在进行,就像上面高亮的边框显示的那样)和 isAutoScrolling(自动滚动正在滚动它的容器)。完整参考:makeDragArea。

区域如何组合

  • 拖拽遵循它开始时所在的区域,即使离开了它也一样。从某个区域上方经过不会改变任何东西,除了它的 autoScroll:它适用于每一个滚动其容器的拖拽。
  • 嵌套的区域不会合并:最近的那个胜出。disabled 是例外,它会被继承:关闭的区域也会关闭其内部的区域。
  • 碰撞检测、键盘按键和播报仍在 provider 范围内生效。
为什么不在画板外面再包一个 DnDProvider?provider 之间相互独立:拖拽永远不会从一个跨到另一个,所以画板中的项永远无法离开它。只有当页面的某一部分需要自己的 provider 级设置,并且其中的项留在其内时,才嵌套 provider。见 provider。

放在哪就留在哪

上面的时间线、通话、流程图、幻灯片和区域,都会把每个项留在它被放置的地方。每个拖拽事件都带有 position:被拖拽盒子在目标内部的左上角,从目标的内边距边缘量起,并加上它的滚动距离——也就是能让绝对定位的项正好停在那里的 left 和 top。它在修饰器之后计算,所以吸附后的节点会落在网格上。

Flowchart.vue
makeDroppable(canvas, { id: 'canvas' });
useDnDMonitor({
  onDragEnd({ active, over, position }) {
    const node = nodes.value.find((item) => item.id === active.id);
    if (!node || over?.id !== 'canvas' || !position) return;
    node.x = Math.round(position.x);
    node.y = Math.round(position.y);
  },
});

更多关于自由拖拽和放置目标的内容,见拖放。