[Vue3]自定义指令实现组件元素可拖拽移动

Source

实现思路:

元素移动设计思路

1.在光标按下的时刻记录下光标的绝对位置坐标(以视窗左上角为原点)(const {clientX, clientY} = evt

clientX / clientY 事件属性返回当事件被触发时光标指针相对于浏览器页面当前 body 可视区域的x, y坐标。

2.记录此时光标相对目标元素的位置。需要获取目标元素的绝对位置坐标(const {x, y} = el.getBoundingClientRect()),计算并记录光标相对目标元素的相对位置坐标pointerRelativePos

el.getBoundingClientRect() 获取元素的大小及其相对于视口的位置。

3.在光标按下移动过程中监听光标变化的绝对位置坐标(clientX / clientY),并根据光标按下时刻记录的光标相对坐标pointerRelativePos来推算出目标元素此时应该定位的绝对位置坐标,并设置目标元素的样式属性inset为对应的坐标位置。
4.最后在光标解除按压时停止监听光标移动。

上述参数表现如图所示:

事件监听设计思路

1.光标按下的时刻采用pointerdown事件监听,此事件支持PC和移动设备(包括鼠标、触摸点和触摸笔)
2.光标移动事件监听PC端采用pointermove,而移动端采用touchmove监听。至于移动端为什么不采用pointermove,由于本人实践发现有一个很奇怪的bug,即移动端触点在移动过程中会随机触发pointerleave,导致pointermove事件中断(即使是把事件pointermove事件挂载在document上也无法避免,暂时没有找到原因,有大佬了解的还望指点)。故这里监听移动端触点移动事件采用touchmove
3.最后停止监听光标移动在触发pointerup事件时接触对pointermovetouchmove事件的监听。

处理细节

1.当触发目标元素的pointerdown事件后,直接在document而非目标元素上添加pointermove事件处理函数。解决在PC端鼠标移动过快而导致目标元素还没有及时调整样式位置到鼠标所在位置时鼠标就移除了目标元素从而触发pointerleave事件导致pointermove事件中断。在document上添加pointermove事件处理函数就不会发生pointerleave的情况(在移动端无效,上面已经提到)
2.监听目标元素的touchmove事件时最好evt.preventDefault(),可以防止其触发默认页面滚动事件。当然本人在代码中设置了可以选择不这么做,当你真的需要滚动页面而不是移动目标元素时。
3.当触发目标元素的pointerdown事件后,设置了一个计时器,默认超出250ms后执行,在此期间若触发了pointerup事件则取消定时器执行,执行内容主要是监听目标元素click事件的捕获阶段并阻止其向后冒泡。解决了在PC端支持目标元素点击事件的效果。实现效果是当鼠标短点击(按下和提起时间在250ms内)目标元素则会正常触发点击事件,当鼠标长按(按下和提起时间超过250ms)目标元素则不会触发其本身挂载的点击事件处理函数,而是触发元素的拖拽移动行为。当然这里的延迟时间250ms只是默认值,可以自定义。

实现效果:

首先是示例代码demo:

<script setup lang="ts">
const onclick = () => console.log('点击了一下')
</script>

<template><div v-draggable="{ className: 'controller-move' }" class="controller" @click="onclick">拖拽移动</div>
</template>
<style scoped>
.controller {position: fixed;top: 100px;left: 100px;z-index: 100;width: 100px;height: 100px;border: 10px solid;border-radius: 10px;line-height: 100px;text-align: center;
}

.controller-move {transform: scale(1.5);filter: opacity(75%);cursor: move;
}
</style> 

其效果如下:

PC端:

使用方法:

1.在 vue 项目 src 根路径 main.js 对createApp创建的app对象使用app.directive('draggable', draggable);挂载全局指令。
2.在 vue 文件的 template 中使用 v-draggable 来挂载该指令在目标元素上
3.指令绑定值传参:即v-draggable=“value”,value中的传参格式如下,也可以什么参数都传。

* {
* device?: 'mobile' | 'pc';设备,传参: 'mobile' | 'pc' | undefined
* ms?: number; 延迟时间, default 250. 与 click 事件区分
* o?: Origin;移动时的相对原点,支持4种, 0: 左上角; 1: 左下角; 2: 右下角; 3: 右上角; 默认为 0: 左上角
* axes?: 'x' | 'y';移动轴,传参: 'x' | 'y' | undefined, 默认 undefined, 即光标位置; 可选择只沿x轴移动或只沿y轴移动
* style?: Partial<CSSStyleDeclaration>;移动过程中 el 的 style 样式, !important: 不要在移动过程中的样式中设置与位置有关的样式属性,如:position、inset、top、left、right、bottom
* className?: string;移动过程中 el 的 class 样式
* setPassive?: () => boolean;设置 touchmove 事件的 passive 属性, 默认 undefined , 阻止 touchmove 默认事件(页面滚动); 若为 true , 不阻止默认事件, 页面可滚动
* onMove?: (el: HTMLElement) => void;开始移动时触发的回调
* onStop?: (el: HTMLElement) => void;停止移动时触发的回调
* } 

以下摘取部分v-draggable指令实现源码:

/**
 * @title draggable
 * @description 使元素可拖拽移动,支持PC、移动端设备(vue directive)
 * @author wzdong
 * @param el 目标元素,目标元素必须是支持 inset 布局,建议 position: fixed
 * @param binding 绑定对象
 */

import { DirectiveBinding } from 'vue';

const draggable = ( el: HTMLElement,{value: {device,ms = 250,o: origin = Origin['topLeft'],axes,style,className,setPassive,onMove,onStop,} = {} as any,}: DirectiveBinding<BindingValue> ) => {... ...let pointerRelativePos: { x: number; y: number },widthHeight: {offsetWidth: number;offsetHeight: number;innerWidth: number;innerHeight: number;},timer: number;// 获取元素宽高以及视窗宽高const getWidthHeight = () => {const { offsetWidth, offsetHeight } = el;const { innerWidth, innerHeight } = window;widthHeight = { offsetWidth, offsetHeight, innerWidth, innerHeight };};getWidthHeight();// 记录指针相对元素位置const recordPointerPos = (clientX: number, clientY: number) => {const { x, y } = el.getBoundingClientRect();pointerRelativePos = {x: clientX - x,y: clientY - y,};};const insetStyle = Array(4).fill('auto');// 设置目标元素位置,以指针为基点const setElPos = (clientX: number, clientY: number) => {const { x, y } = pointerRelativePos;const left = clientX - x;const top = clientY - y;const { offsetWidth, offsetHeight, innerWidth, innerHeight } =widthHeight;const insetAllStyle = `${top}px ${innerWidth - offsetWidth - left}px ${innerHeight - offsetHeight - top}px ${left}px`.split(' ');... ...el.style.inset = insetStyle.join(' ');};// 移动中// 适用于PC, 移动设备 touch 会不定时触发 pointerleave, 无法用 onpointermove 监听const onPointermove = (evt: MouseEvent) => {const { clientX, clientY } = evt;setElPos(clientX, clientY);};// 适用于移动设备const onTouchmove = (evt: TouchEvent) => {// 阻止触摸页面滑动!setPassive?.() && evt.preventDefault();el.removeEventListener('pointermove', onPointermove);const { clientX, clientY } = evt.touches[0];setElPos(clientX, clientY);};// 开始移动const onPointerdown = (evt: PointerEvent) => {const { clientX, clientY } = evt;recordPointerPos(clientX, clientY);getWidthHeight();device !== 'pc' &&el.addEventListener('touchmove', onTouchmove, {passive: !!setPassive?.(),capture: true,});device !== 'mobile' &&document.addEventListener('pointermove', onPointermove, true);timer = setTimeout(() => {onMove?.(el);setStyle(el);// pc端支持长按click事件,因此这里要判断超出 ms 时长即判定为拖拽而非 click// 捕获阶段阻止冒泡,中断之后的事件流el.addEventListener('click',(evt: MouseEvent) => {evt.stopPropagation();},{ capture: true, once: true });}, ms);el.addEventListener('pointerup', stopMove, { once: true });};el.addEventListener('pointerdown', onPointerdown, true);// 停止移动const stopMove = () => {clearTimeout(timer);document.removeEventListener('pointermove', onPointermove, true);el.removeEventListener('touchmove', onTouchmove, true);onStop?.(el);setStyle(el, true);};
};

export default { mounted: draggable }; 

最后

最近还整理一份JavaScript与ES的笔记,一共25个重要的知识点,对每个知识点都进行了讲解和分析。能帮你快速掌握JavaScript与ES的相关知识,提升工作效率。



有需要的小伙伴,可以点击下方卡片领取,无偿分享