传感器把输入变成拖拽。它只告诉 provider“从这里拖拽这个项”,然后告诉它输入移动到了哪里,以及是放下了还是取消了。其余的都由 provider 完成,对每个传感器都一样:一次一个拖拽、拒绝禁用的项、激活约束、排序、自动滚动、播报、:kbd{value="Escape"} 以及整个放置过程。
内置传感器
| 传感器 | 输入 |
|---|---|
pointerSensor | 通过 Pointer Events 处理鼠标、触摸和触控笔。它会捕获指针,防止页面在触摸拖拽时选中文本或滚动,并吞掉拖拽之后的那次点击。 |
keyboardSensor | Space 和 Enter 拿起和放下,方向键移动,:kbd{value="Escape"} 取消——任何拖拽都可以,指针拖拽也一样。 |
两者默认都会运行。provider 的 sensors 会替换这个列表:
<DnDProvider :sensors="[pointerSensor, keyboardSensor, joystick.sensor]">
要关闭键盘拖拽,优先使用 :keyboard="false",而不是去掉 keyboardSensor:键盘传感器同时也负责用 Escape 取消任何拖拽。它的按键可以用 keyboard.keys 重新绑定(见无障碍)。
激活约束
按下不会立刻开始拖拽:它会等待激活约束满足,这样点击仍然是点击,滑动仍然可以滚动页面。切换这个列表的规则,然后按住一行:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Inbox from './Inbox.vue';
</script>
<template>
<DnDProvider>
<Inbox />
</DnDProvider>
</template>约束有三个字段:
| 字段 | 拖拽开始于…… |
|---|---|
distance | 指针移动了这么多 px 之后。 |
delay | 按下持续了这么多 ms 之后。同时设置了 distance 时,取先满足的那个。 |
tolerance | 与 delay 配合:等待期间指针可以移动多远。超出时按下放弃,让页面可以滚动。没有 distance 时默认为 5 px。 |
没有自定义约束时,每种指针类型都有自己的默认值:
| 指针 | 默认值 |
|---|---|
| 鼠标 | 移动 4 px 之后。在 handle 上按下时立即开始,除非设置了鼠标约束。 |
| 触控笔 | 移动 4 px 之后。 |
| 触摸 | 按下 200 ms 之后。在此之前移动超过 5 px 会放弃,改为滚动页面。 |
在哪里设置
按指针类型——mouse、touch、pen——设置在 provider、拖拽区域或项上。对于每种类型,项自己的优先,然后是它所在区域的,最后是 provider 的:
<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 250, tolerance: 5 } }">
// Inbox.vue: the rules of every drag that starts inside the panel.
makeDragArea(panel, { activation: () => MODES[mode.value].activation });
// One item: a longer press by touch, the area's rule for the mouse.
makeSortableItem(el, { item: () => props.task, activation: { touch: { delay: 400, tolerance: 8 } } });
这些选项在按下开始时读取,所以 getter 或 ref 可以实时切换它们,就像演示那样。键盘拖拽立即开始。自定义传感器可以要求它自己的约束,或某种指针类型所配置的约束。
长按圆环
按下等待期间,useDnD().status 为 'activating',activation.progress 从 0 走到 1——使用 delay 时按时间,使用 distance 时按移动的距离。演示在按下的位置画出一个圆环:
<script setup lang="ts">
const { status, activation } = useDnD();
</script>
<template>
<svg v-if="status === 'activating'" class="ring" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="17" pathLength="1" :stroke-dasharray="`${activation.progress.value} 1`" />
</svg>
</template>
给圆环设置 pointer-events: none:它位于指针下方,而拖拽要对那里的东西做命中测试。放弃的按下——移动超出了 tolerance,或松开得太早——会让 status 直接回到 'idle'。
手柄立即开始
有 handle 时,只有手柄能开始拖拽,在它上面按下鼠标会立即开始拖拽:抓住抓手这个动作已经没有需要区分的了。在按下的地方松开仍然是点击,所以抓手可以在 click 时打开菜单(指针只在移动后才会被捕获)。触摸仍会等待长按,除非手柄无法滚动页面。给手柄加上 touch-action: none,并给项设置一个较短的距离:
makeSortableItem(el, {
item: () => props.chat,
handle: '.grip',
// The grip has `touch-action: none`: a touch on it can't scroll, so it drags after 4 px.
activation: { touch: { distance: 4 } },
});
在项、它所在区域或 provider 上设置的鼠标约束,同样适用于手柄。像演示那样把抓手做成 <button>:它能获得焦点,所以键盘拖拽也能从它开始。
只允许部分按下
canDrag 是可拖拽元素和可排序项上的又一道关卡。它接收输入事件,并针对这次按下回答 true 或 false。这里指针拖拽需要按住 Shift;键盘照常拖拽:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Checklist from './Checklist.vue';
</script>
<template>
<DnDProvider>
<Checklist />
</DnDProvider>
</template>makeSortableItem(el, {
item: () => props.task,
// Never: the row is locked (and says so to screen readers with aria-disabled).
disabled: () => !!props.task.locked,
// Per press: pointer drags only with Shift held; the keyboard as usual.
canDrag: (event) => {
if (!props.shiftOnly || event instanceof KeyboardEvent || event.shiftKey) return true;
emit('refused');
return false;
},
});
| 选项 | 询问时机 | 用途 |
|---|---|---|
disabled | 实时,并设置为 aria-disabled | 完全不能移动的项:被锁定的、只读的。 |
canDrag | 每次按下询问一次,带上它的事件 | 关于输入的规则:按住某个键、某个按钮、某种指针类型。 |
handle | 按下的位置 | 只能从某一部分开始拖拽的项。 |
canDrag 在激活约束之前、随 pointerdown 或 keydown 被询问。指针类型是它可以制定的又一条规则:
// Mouse and pen drag; a touch on the row always scrolls the page.
canDrag: (event) => !(event instanceof PointerEvent && event.pointerType === 'touch'),
可交互的子元素——按钮、输入框、复选框、可编辑字段——永远不会开始指针拖拽,所以它们在可拖拽元素内部照常工作,就像演示中的复选框和负责人菜单那样。有 handle 时,只有手柄能开始拖拽。
自定义传感器
游戏手柄、屏幕摇杆、语音命令:任何能指向一个项并移动的东西都可以驱动拖拽。这个游戏手柄就是一个传感器。在一个英雄上方按住 A 拿起它,用方向键操控,再按 A 放下,按 B 取消。鼠标和键盘在它旁边仍然可用:
正在加载演示…
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue';
import { DnDProvider, keyboardSensor, pointerSensor } from '@vue-dnd-kit/core';
import Party from './Party.vue';
import Gamepad from './Gamepad.vue';
import { createJoystick } from './joystick';
const stage = useTemplateRef<HTMLElement>('stage');
const joystick = createJoystick(() => stage.value);
// The gamepad drives drags next to the mouse, touch and the keyboard.
const sensors = [pointerSensor, keyboardSensor, joystick.sensor];
onMounted(() => {
const first = stage.value?.querySelector('li');
if (first) joystick.aim(first);
});
</script>
<template>
<DnDProvider :sensors="sensors">
<div ref="stage" class="arcade">
<Party />
<Gamepad :joystick="joystick" />
<span class="cursor" :style="{ left: `${joystick.cursor.value.x}px`, top: `${joystick.cursor.value.y}px` }" />
</div>
</DnDProvider>
</template>
<style scoped>
.arcade {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 32px 48px;
padding: 8px;
}
/* The gamepad's cursor: above everything, never what a hit test finds. */
.cursor {
position: absolute;
z-index: 1;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid #42b883;
border-radius: 50%;
background: color-mix(in srgb, #42b883 25%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, #42b883 15%, transparent);
pointer-events: none;
}
</style>defineSensor 给传感器一个名称和一个 setup,setup 接收 provider 一侧的接口,每个 provider 调用一次:
const sensor = defineSensor({
name: 'joystick',
// Once per provider, in the browser. The cleanup runs when the provider unmounts.
setup(ctx) {
context = ctx;
return () => {
context = null;
drag = null;
};
},
});
按下时用 activate() 向 provider 请求一次拖拽。目标会像真正的按下一样被检查:它必须是此 provider 的可拖拽元素、没有被禁用,并且不是其中的按钮:
/** A goes down: drops a running drag, or starts a hold over what is under the cursor. */
const press = () => {
if (drag?.state === 'dragging') return void drag.drop();
const at = point();
const target = document.elementFromPoint(at.x, at.y);
if (!target || !context) return;
drag = context.activate({
target, // checked like a real press: a draggable of this provider, not disabled
point: at,
type: 'pointer', // follows points: lists sort under it, containers auto-scroll
activation: { delay: 300 }, // `useDnD().activation.progress` runs meanwhile
onEnd: (ended) => {
if (drag === ended) drag = null;
},
}); // null: nothing to drag there
};
然后拖拽随方向键移动,A 或 B 结束它:
const move = (dx: number, dy: number) => {
cursor.value = { x: cursor.value.x + dx, y: cursor.value.y + dy }; // clamped in the demo
drag?.move(point()); // pending: checks the tolerance; dragging: moves
};
const release = () => {
if (drag?.state === 'pending') drag.cancel(); // A let go before the hold is over
};
const cancel = () => drag?.cancel();
像演示那样把传感器加到 provider 的 sensors 中,或用 useSensor(joystick.sensor) 让它在某个组件存续期间运行。点使用视口坐标:演示把它的光标保存在舞台的坐标中,这样光标会随页面滚动,并在 point() 中进行转换。
传感器能拿到什么
setup(context) 接收 provider 一侧的接口:
| 成员 | 是什么 |
|---|---|
props | provider 的 props(activation、keyboard……)。在需要时读取:它们可能会变化。 |
status | provider 的拖拽状态,无论是哪个传感器在运行拖拽。 |
drag | provider 当前的拖拽,等待中或进行中,无论由哪个传感器启动。没有时为 null。 |
activate(options) | 请求一次拖拽。返回 SensorDrag,不能开始拖拽时返回 null。 |
cancel() | 取消 provider 的拖拽或等待中的按下。 |
activate() 接受:
| 选项 | 含义 |
|---|---|
target | 输入落在哪里:可拖拽元素内部的一个元素,或一个可拖拽元素的 id。元素会像真正的按下一样被检查(手柄、不是可交互的子元素);id 会跳过这些检查。 |
point | 拖拽开始的位置,使用视口坐标。默认:可拖拽元素的中心。 |
type | 'pointer'(默认):跟随点、对目标做命中测试、自动滚动。'keyboard':按项移动、把目标滚动到可见范围、结束时归还焦点。 |
activation | 一个约束({ delay: 300 }),或一种指针类型('touch'),以使用为其配置的约束。默认:无,拖拽立即开始。 |
event | 输入事件,会交给项的 canDrag(没有它时不会询问 canDrag)。触摸的 pointerType 会让它成为触摸拖拽(触感反馈)。 |
onStart(drag) | 拖拽在激活之后开始了。 |
onEnd(drag) | 拖拽结束了:被中止、放下或取消——无论由谁触发。 |
onResume(drag) | onBeforeDrop 返回了 'continue':拖拽继续,move、drop 和 cancel 再次生效。 |
它返回的 SensorDrag:
| 成员 | 作用 |
|---|---|
id、type | 被拖拽项的 id,以及 'pointer' 或 'keyboard'。 |
state | 'pending'(等待激活)、'dragging',然后是 'ended'。 |
started | 拖拽已经开始,即使之后已经结束。 |
move(point) | 输入移动了。等待期间检查 tolerance;拖拽期间移动拖拽。 |
step(direction) | 键盘拖拽:按一次方向键的步进,'up' | 'down' | 'left' | 'right'。 |
place({ list, index }) | 把项放到可排序列表的某个位置,与键盘移动一样。没有列表在那里接收它时,结果为 false。 |
drop() | 放下。放置完成并落位后,结果为 true。 |
cancel() | 取消:项飞回原处,或放弃等待中的按下。 |
已不再适用的调用——结束之后的移动、第二次 drop()——会被忽略。完整类型:传感器。
setup。用 Object.create(context, { activate: { value: myActivate } }) 创建这个上下文,而不是用展开运算符,这样实时的 getter(status、drag、props)才能继续工作。程序化拖拽
@vue-dnd-kit/actions 通过代码移动项:“上移”按钮、菜单、键盘快捷键、撤销、新手引导、测试。它基于公开的传感器 API 构建,它的拖拽是真正的拖拽:会被播报、带有动画,经过 onBeforeDrop、onDrop、e.result 和 preventDefault(),并遵循 provider 的规则(一次一个拖拽、禁用的项、accept 和 group.put)。
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Queue from './Queue.vue';
</script>
<template>
<!-- @vue-dnd-kit/actions moves the rows from code: buttons, a shuffle, a scripted drag. -->
<DnDProvider>
<Queue />
</DnDProvider>
</template>npm i @vue-dnd-kit/actions
moveItem:一次调用,一次移动
import { useDnDActions } from '@vue-dnd-kit/actions';
const { moveItem } = useDnDActions(); // in setup, under a provider
await moveItem('card-1', { index: 0 }); // to the top of its list
await moveItem('card-1', { list: 'done' }); // to the end of another list
await moveItem('card-1', { list: 'done', index: 2 }); // to a place in another list
list是可排序列表的id。不传时,项留在它所在的列表中。index是项最终所在的位置,不把项本身计算在内,并被限制在列表范围内。不传时,项移到末尾。- 放置完成时结果为
true;项没有被渲染或被禁用、没有列表在那里接收它,或放置被拒绝时为false。 - 调用会排队:每次调用都会等到 provider 空闲,所以快速的第二次点击不会丢失。演示中的打乱就是连续五次调用。
- 它以键盘拖拽的方式运行,所以会被播报,焦点也保持在项上。
- 仅限列表:树中的位置还有深度。
带移动按钮的行见无障碍。
startDrag:脚本化的拖拽
const { startDrag } = useDnDActions();
const drag = startDrag('card-1'); // null when it can't start now
if (drag) {
await drag.moveTo(document.querySelector('#trash')!); // onto an element
await drag.moveTo({ x: 400, y: 120 }); // to a point, in viewport coordinates
await drag.moveTo({ list: 'done', index: 2 }); // to a list position
await drag.drop(); // true when the drop went through; or drag.cancel()
}
moveTo 在移动渲染完成后完成,drop 在放置落位后完成。演示中的“Play a scripted drag”用一小步一小步的 moveTo 把最后一首曲目滑上去,然后把它放在最顶部。startDrag(id, { from: { x, y } }) 会从某个点而不是项的中心拿起它。
把拖拽交给键盘
startDrag(id, { type: 'keyboard' }) 以键盘拖拽的方式拿起项。你的代码可以用 await drag.step('down') 移动它,也可以把它交给用户:键盘传感器驱动每一个键盘拖拽,无论是谁启动的。这样就能做出一个“移动”菜单项:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Planner from './Planner.vue';
</script>
<template>
<!-- The ⋯ menus move rows with @vue-dnd-kit/actions. -->
<DnDProvider>
<Planner />
</DnDProvider>
</template>const { moveItem, startDrag } = useDnDActions();
// One move in one call: picked up, put there and dropped, announced and animated.
const toOther = () => moveItem(props.task.id, { list: props.other.id, index: 0 });
// A keyboard drag, handed to the arrow keys: Space or Enter drops, Escape cancels.
const withKeys = () => {
el.value?.focus();
startDrag(props.task.id, { type: 'keyboard' });
};
方向键按位置移动这一行,:kbd{value="ArrowLeft"} 和 ArrowRight 把它移到另一周(这两个列表共享一个 group)。拖拽结束时,焦点回到这一行。当 useDnD().active.value?.sensor 为 'keyboard' 时,看板下方的按键提示会高亮。
在组件之外
useDnDActions() 在上方的 provider 中运行一个名为 actions 的传感器。要从 store 或测试工具驱动 provider,或使用你自己的名称,在你自己运行的传感器中创建 actions:
import { defineSensor } from '@vue-dnd-kit/core';
import { createDnDActions, type DnDActions } from '@vue-dnd-kit/actions';
export let tour: DnDActions | undefined;
export const tourSensor = defineSensor({
name: 'tour',
setup: (context) => void (tour = createDnDActions(context)),
});
<DnDProvider :sensors="[pointerSensor, keyboardSensor, tourSensor]">
然后就可以在任何地方调用 await tour?.moveItem('card-1', { list: 'done' })。在服务端渲染期间不会发生任何拖拽:moveItem 的结果为 false,startDrag 返回 null。