【记】Vue - 拖拽元素组件实现

2,948 阅读3分钟

需求描述:

  1. 可实现PC/移动端元素拖拽移动
  2. 支持2种模式:
    1. 元素跟随光标点放置
    2. 元素在光标点平齐位置靠侧边吸附

市面上估计有很多这种组件和功能了,但我没找到合适的,用了VueUse的useDraggable Function感觉不太适合某些应用场景(比如需要拖拽的点击button),故自己手动实现了一下,此次实现也算是对事件处理以及元素定位的相关属性有了比较深入的了解了,仅以本文记录一下。也欢迎大佬们批评指正。

实现思路:

整体思路

  1. 组件元素包括三部分:
    1. 移动容器
    2. 可拖拽元素
    3. 操作元素

移动容器包裹可拖拽元素和操作元素,且可拖拽元素和操作元素在页面中二者只显示其一。 当props.snapEdge === false时,可拖拽元素和操作元素为同一个,通过default slot传入; 当props.snapEdge === true时,可拖拽元素为snapEdge slot传入的元素,操作元素为default slot传入的元素。

  1. 拖拽可拖拽元素,可以放置整个移动容器的位置,支持2种方式:
    1. 在光标所在处放置容器
    2. 在光标所在平齐处放置元素靠侧边吸附

两种方式切换通过props.snapEdge设置。

细节思路

  1. DragEvent实现PC端拖拽功能

PC端拖拽可通过DragEvent事件监听(ondragstart、ondragend)【为什么不用MouseEvent(onmousedown、onmousemove、onmouseup、……),主要考虑是为了防止和内部元素的click事件冲突。vueuse中的useDraggable Function就存在这个问题,useDraggable Function源码中是通过PointerEvent事件监听,而PointerEvent是继承自MouseEvent,对其源码感兴趣的可转以上链接】 image.png 在drag事件执行过程中会判断2个因素:

  1. 可拖拽元素:可拖拽元素通过draggable属性设置;
  2. 可放置的目标元素:默认情况下,浏览器会阻止在大多数 HTML 元素上放置某些内容时发生任何事情。要想目标元素变为可放置元素,该元素需要通过ondragover事件来阻止默认事件的发生。

即通过对拖拽元素本身和其父元素中添加ondragover事件

const prevent = (evt: DragEvent) => {
    evt.preventDefault();
    evt.dataTransfer.dropEffect = 'move'
};
dragContainerRef.value.addEventListener('dragover', prevent);
dragContainerRef.value.parentNode.addEventListener('dragover', prevent);
  1. TouchEvent实现移动端拖拽功能

移动端拖拽可通过TouchEvent事件监听(ontouchstart、ontouchmove、ontouchend)

  1. 元素随光标移动实现

在按下元素后记录鼠标相对元素的位置,在之后的光标移动过程中修改元素的位置使其始终保持和光标的相对位置。

代码实现:

效果演示: 1111.gif

<template>
    <Drag-Elem class="drag-btn" :snapEdge="true">
      <button @click="onClick">💛操作元素</button>
      <template #snapEdge>
        <button @touch="onClick">💛</button>
      </template>
    </Drag-Elem>
</template>
<style>
  .drag-btn {
    bottom: 100px;
    left: 10px;
  }
</style>
<script setup>
import DragElem from '@/components/myUI/DragElem.vue';
const onClick = () => alert('💛点击')
</script>

------ 最后附上代码:

  1. template & style
<template>
  <!-- 移动容器 -->
  <div ref="dragContainerRef" class="drag-container" :style="dragContainerStyle">
    <!-- 可拖拽元素,拖拽该元素会对整个移动容器进行移动 -->
    <div draggable="true" @dragstart="onDragstart" @dragend="onDragend" @touchstart="onTouchstart"
      @touchmove="onTouchmove" @touchend="onTouchend">
      <div :style="dragElemStyle">
        <div v-show="$slots.snapEdge&&isShowSnapEdgeElem" @mouseup="unShowSnapEdgeElem">
          <slot name="snapEdge"></slot>
        </div>
        <div v-show="!$slots.snapEdge">
          <slot></slot>
        </div>
      </div>
    </div>
    <!-- 操作元素,由可拖拽元素点击触发弹出 -->
    <div v-show="$slots.snapEdge && !($slots.snapEdge && isShowSnapEdgeElem)">
      <slot></slot>
    </div>
  </div>
</template>

<style>
  .drag-container {
    position: fixed;
    z-index: 10;
  }
</style>
  1. typescript
import { onMounted, reactive, ref } from 'vue';

// component props
// :snapEdge="true" 开启元素侧边栏吸附 
const props = defineProps({
  snapEdge: {
    type: Boolean,
    required: false,
  },
})
// 移动容器位置
const dragContainerStyle = reactive({
  top: '',
  left: '',
  bottom: '',
  right: '',
});
// 设置可拖拽元素拖拽时大小(仅对移动端生效)
const SCALE = '1.5'
const dragElemStyle = reactive({
  transform: 'scale(1)',
})
// 是否显示侧边栏吸附元素,仅在使用了$slots.snapEdge插槽时生效
const isShowSnapEdgeElem = ref(false)

const dragContainerRef = ref<HTMLElement>(null)
const initLocation = () => {
  const { offsetLeft, offsetTop } = dragContainerRef.value
  dragContainerStyle.top = offsetTop + 'px'
  dragContainerStyle.left = offsetLeft + 'px'
  dragContainerStyle.bottom = 'auto'
  dragContainerStyle.right = 'auto'
  setElemSnapEdgeLocation()
}
onMounted(initLocation)


let pointerRelativeX: number, pointerRelativeY: number;
// 记录指针相对元素位置
const recordPointerLocation = (clientX: number, clientY: number) => {
  pointerRelativeX = clientX - dragContainerRef.value.offsetLeft;
  pointerRelativeY = clientY - dragContainerRef.value.offsetTop;
};
// 模式一:设置目标元素位置,以指针为基点
const setElemLocation = (clientX: number, clientY: number) => {
  const left = clientX - pointerRelativeX;
  const top = clientY - pointerRelativeY;
  dragContainerStyle.right = 'auto';
  dragContainerStyle.bottom = 'auto';
  dragContainerStyle.top = top + 'px';
  dragContainerStyle.left = left + 'px';
};
// 模式二:设置目标元素吸附位置
const setElemSnapEdgeLocation = () => {
  if (!props.snapEdge) return;
  const { offsetLeft, offsetWidth } = dragContainerRef.value
  const { innerWidth } = window
  if (offsetLeft + offsetWidth / 2 < innerWidth / 2) {
    dragContainerStyle.left = '0px'
    dragContainerStyle.right = 'auto'
  } else {
    dragContainerStyle.right = '0px'
    dragContainerStyle.left = 'auto'
  }
  isShowSnapEdgeElem.value = true
};

// 隐藏吸附边缘的元素,显示操作元素
const unShowSnapEdgeElem = () => {
  isShowSnapEdgeElem.value = false
  setTimeout(() => { document.addEventListener('click', showSnapEdgeElem) })
}
const showSnapEdgeElem = () => {
  document.removeEventListener('click', showSnapEdgeElem)
  isShowSnapEdgeElem.value = true
}

// pc端鼠标拖拽事件
const onDragstart = (evt: DragEvent) => {
  evt.preventDefault();
  evt.stopPropagation();
  const { clientX, clientY } = evt;
  dragContainerRef.value.addEventListener('dragover', prevent);
  dragContainerRef.value.parentNode.addEventListener('dragover', prevent);
  recordPointerLocation(clientX, clientY);
};
const onDragend = (evt: DragEvent) => {
  evt.preventDefault();
  evt.stopPropagation();
  const { clientX, clientY, target } = evt;
  dragContainerRef.value.removeEventListener('dragover', prevent);
  dragContainerRef.value.parentNode.addEventListener('dragover', prevent);
  setElemLocation(clientX, clientY);
  setElemSnapEdgeLocation()
};
const prevent = (evt: DragEvent) => {
  evt.preventDefault();
  evt.dataTransfer.dropEffect = 'move'
};

// 移动端
const onTouchstart = (evt: TouchEvent) => {
  evt.stopPropagation();
  const { clientX, clientY } = evt.touches[0];
  recordPointerLocation(clientX, clientY);
  dragElemStyle.transform = `scale(${SCALE})`
};
const onTouchmove = (evt: TouchEvent) => {
  evt.preventDefault();
  evt.stopPropagation();
  const { clientX, clientY } = evt.touches[0];
  setElemLocation(clientX, clientY);
};
const onTouchend = (evt: TouchEvent) => {
  evt.stopPropagation();
  setElemSnapEdgeLocation()
  dragElemStyle.transform = `scale(1)`
};