文档/指南

传感器与程序化拖拽

输入如何变成拖拽——指针和键盘传感器、激活约束、canDrag、自定义传感器,以及用 @vue-dnd-kit/actions 编写脚本驱动的拖拽。

编辑此页

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

传感器把输入变成拖拽。它只告诉 provider“从这里拖拽这个项”,然后告诉它输入移动到了哪里,以及是放下了还是取消了。其余的都由 provider 完成,对每个传感器都一样:一次一个拖拽、拒绝禁用的项、激活约束、排序、自动滚动、播报、:kbd{value="Escape"} 以及整个放置过程。

内置传感器

传感器输入
pointerSensor通过 Pointer Events 处理鼠标、触摸和触控笔。它会捕获指针,防止页面在触摸拖拽时选中文本或滚动,并吞掉拖拽之后的那次点击。
keyboardSensorSpace 和 Enter 拿起和放下,方向键移动,:kbd{value="Escape"} 取消——任何拖拽都可以,指针拖拽也一样。

两者默认都会运行。provider 的 sensors 会替换这个列表:

<DnDProvider :sensors="[pointerSensor, keyboardSensor, joystick.sensor]">

要关闭键盘拖拽,优先使用 :keyboard="false",而不是去掉 keyboardSensor:键盘传感器同时也负责用 Escape 取消任何拖拽。它的按键可以用 keyboard.keys 重新绑定(见无障碍)。

激活约束

按下不会立刻开始拖拽:它会等待激活约束满足,这样点击仍然是点击,滑动仍然可以滚动页面。切换这个列表的规则,然后按住一行:

Live

正在加载演示…

<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 时按移动的距离。演示在按下的位置画出一个圆环:

Inbox.vue
<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,并给项设置一个较短的距离:

Message.vue
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;键盘照常拖拽:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Checklist />
  </DnDProvider>
</template>
Task.vue
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 取消。鼠标和键盘在它旁边仍然可用:

Live

正在加载演示…

<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 调用一次:

joystick.ts
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 的可拖拽元素、没有被禁用,并且不是其中的按钮:

joystick.ts
/** 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 结束它:

joystick.ts
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 一侧的接口:

成员是什么
propsprovider 的 props(activation、keyboard……)。在需要时读取:它们可能会变化。
statusprovider 的拖拽状态,无论是哪个传感器在运行拖拽。
dragprovider 当前的拖拽,等待中或进行中,无论由哪个传感器启动。没有时为 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)。

Live

正在加载演示…

<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') 移动它,也可以把它交给用户:键盘传感器驱动每一个键盘拖拽,无论是谁启动的。这样就能做出一个“移动”菜单项:

Live

正在加载演示…

<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>
Row.vue
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。