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

4,659 阅读7分钟

在此之前写过一篇文章讲述怎么基于vue实现一个拖拽移动组件,文章链接如下:

【记】Vue - 拖拽元素组件实现 - 掘金 (juejin.cn)

但在后续实践中尝试仍然存在一些小问题,比如在PC端 DragEvent 事件兼容性问题以及受其它元素事件影响等原因导致PC端的拖拽无法很好实现。以下本人修复PC端拖拽方案,将监听事件 DragEvent 改为监听 pointermove 事件。同时修复了一些兼容性问题,本文实现的这个指令绝对好用,稳定性亲测绝对没问题。 另外为之前的这篇文章填一个坑,之前文章里提到 vueuse 中采用的 useDraggable 是通过 PointerEvent 事件实现监听的,而此事件会与组件元素的 click 事件相冲突导致 click 事件无法正常进行,这里还是有方法能够处理的,后面会介绍怎么处理。

本文实现源码自取:

github源码地址(觉得有用的话麻烦star一下🌟)

实现思路:

元素移动设计思路

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

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

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

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

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

上述参数表现如图所示: image.png

事件监听设计思路

  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端: bandicam-2022-12-06-21-11-46-746.gif 移动端: bandicam-2022-12-06-21-12-58-753.gif

使用方法:

  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 };