前言

移动端开发的时候,总是会遇到编写全局浮动组件的情况,比如客服图标啦,消息通知啦,但是浮动就会产生一个问题:阻挡了下层内容展示,常见的做法就是让这个浮动的内容支持拖拽功能,用户将浮动组件拖拽到其他位置即可。

这里我参考了vConsole组件,自己根据实际情况编写了一个拖拽指令。

代码

指令文件:drag.ts

import type { Directive } from "vue";

/** 指令接收的参数 */
interface BindingValue {
    /** 出现位置的偏移量 [x,y] */
    offset?: [number, number];
    /** 缓存的key前缀 */
    storageKey: string;
    /** 是否吸边x轴 */
    adsorptionX?: boolean;
    /** 是否吸边y轴 */
    adsorptionY?: boolean;
    /** 拖拽开始 */
    dragStart?: (event: TouchEvent) => void;
    /** 拖拽中 */
    dragMove?: (event: TouchEvent) => void;
    /** 拖拽结束 */
    dragEnd?: (event: TouchEvent) => void;
}

/** 默认参数 */
type DefaultBindingValue = Required<Omit<BindingValue, "storageKey">>;

/** 拖拽的数据 */
interface DragData {
    x: number;
    y: number;
    startX: number;
    startY: number;
    endX: number;
    endY: number;
    hasMove: boolean;
    init: boolean;
}

/** 拖拽元素 */
interface DragElement extends HTMLElement {
    __dragData: DragData;
    __bindingValue: Required<BindingValue>;
}

/** 缓存位置数据 */
interface LocalPositionData {
    x: number | null;
    y: number | null;
}

/** ios安全底部高度 */
const IOS_SAFE_BOTTOM = 20;

/** 指令参数校验 */
function validateBindingValue(bindingValue: BindingValue): [Error | null, boolean] {
    if (!bindingValue) {
        return [new Error("Missing argument for drag directive!"), false];
    }

    if (typeof bindingValue.storageKey !== "string" || bindingValue.storageKey.trim() === "") {
        const error = new Error(
            "The storageKey parameter of the drag directive must be a string and cannot be an empty string!"
        );
        return [error, false];
    }

    if (!Array.isArray(bindingValue.offset) || bindingValue.offset.length !== 2) {
        return [new Error("The drag directive offset argument must be array and length 2!"), false];
    } else {
        const isNotNumber = bindingValue.offset.some((item) => typeof item !== "number");
        if (isNotNumber) {
            return [new Error("The drag instruction offset array must be all numbers"), false];
        }
    }

    if (typeof bindingValue.adsorptionX !== "boolean") {
        return [new Error("The adsorptionX parameter of the drag directive must be a boolean!"), false];
    }

    if (typeof bindingValue.adsorptionY !== "boolean") {
        return [new Error("The adsorptionY parameter of the drag directive must be a boolean!"), false];
    }

    if (typeof bindingValue.dragStart !== "function") {
        return [new Error("The dragStart parameter of the drag directive must be a function!"), false];
    }

    if (typeof bindingValue.dragMove !== "function") {
        return [new Error("The dragMove parameter of the drag directive must be a function!"), false];
    }

    if (typeof bindingValue.dragEnd !== "function") {
        return [new Error("The dragEnd parameter of the drag directive must be a function!"), false];
    }

    return [null, true];
}

/** 获取设备大小 */
function getDeviceSize() {
    const docWidth = Math.max(document.documentElement.offsetWidth, window.innerWidth);
    const docHeight = Math.max(document.documentElement.offsetHeight, window.innerHeight);

    return {
        width: docWidth,
        height: docHeight
    };
}

/** 获取安全位置 */
function getSafePosition(el: DragElement, x: number, y: number): [number, number] {
    const deviceSize = getDeviceSize();
    const { offset } = el.__bindingValue;
    const minX = offset[0];
    const maxX = deviceSize.width - el.offsetWidth - offset[0];
    const minY = IOS_SAFE_BOTTOM + offset[1];
    const maxY = deviceSize.height - el.offsetHeight - offset[1];

    if (x < minX) x = minX;
    if (x > maxX) x = maxX;
    if (y < minY) y = minY;
    if (y > maxY) y = maxY;
    return [x, y];
}

/** 获取持久化位置数据 */
function getLocalStoragePosition(prefix: string): LocalPositionData {
    const x: string | null = localStorage.getItem(`__${prefix}_x`);
    const y: string | null = localStorage.getItem(`__${prefix}_y`);
    return {
        x: x ? parseInt(x) : null,
        y: y ? parseInt(y) : null
    };
}

/** 设置持久化位置数据 */
function setLocalStoragePosition(prefix: string, x: number, y: number) {
    localStorage.setItem(`__${prefix}_x`, x.toString());
    localStorage.setItem(`__${prefix}_y`, y.toString());
}

/** 初始化位置 */
function initPosition(el: DragElement) {
    const dragData = el.__dragData;
    if (dragData.init) return;

    let flag = false;
    const rect = el.getBoundingClientRect();
    const bindingValue = el.__bindingValue;
    const deviceSize = getDeviceSize();

    //缓存的位置
    const localPosition = getLocalStoragePosition(bindingValue.storageKey);
    if (localPosition.x !== null && localPosition.y !== null) {
        dragData.x = localPosition.x;
        dragData.y = localPosition.y;
        flag = true;
    } else {
        if (rect.left === 0 && rect.bottom === 0) return; //created钩子中初始化 rect是获取不到值的
        dragData.x = rect.left;
        dragData.y = deviceSize.height - rect.bottom;
        flag = true;
    }

    if (flag) {
        el.style.right = "auto";
        el.style.top = "auto";
        el.style.left = dragData.x + "px";
        el.style.bottom = dragData.y + "px";
        dragData.init = true;
    }
}

/** 获取吸边生成的位置 */
function getAdsorptionPosition(el: DragElement, x: number, y: number): [number, number] {
    const deviceSize = getDeviceSize();
    const halfDeviceWidth = deviceSize.width / 2;
    const halfDeviceHeight = deviceSize.height / 2;
    const { offset, adsorptionX, adsorptionY } = el.__bindingValue;

    //x轴吸边
    if (adsorptionX) {
        if (x < halfDeviceWidth) {
            x = offset[0];
        } else {
            x = deviceSize.width - el.offsetWidth - offset[0];
            console.log("🚀 ~ file: drag.ts:193 ~ offset[0]:", offset[0]);
            console.log("🚀 ~ file: drag.ts:193 ~ el.offsetWidth:", el.offsetWidth);
            console.log("🚀 ~ file: drag.ts:193 ~ deviceSize.width:", deviceSize.width);
        }
    }

    //y轴吸边,xy轴吸边默认x轴优先
    if (adsorptionY && !adsorptionX) {
        if (y < halfDeviceHeight) {
            y = IOS_SAFE_BOTTOM + offset[1];
        } else {
            y = deviceSize.height - el.offsetHeight - offset[1];
        }
    }

    return [x, y];
}

/** 开始触摸 */
function onTouchStart(event: TouchEvent) {
    const el = event.currentTarget as DragElement;
    const dragData = el.__dragData;
    const bindingValue = el.__bindingValue;
    const touchData = event.touches[0];

    //回调
    bindingValue.dragStart(event);

    dragData.startX = touchData.clientX;
    dragData.startY = touchData.clientY;
    dragData.hasMove = false;

    //初始化方位
    initPosition(el);
}

/** 触摸移动 */
function onTouchMove(event: TouchEvent) {
    event.preventDefault();
    if (event.touches.length <= 0) return;
    const el = event.currentTarget as DragElement;
    const dragData = el.__dragData;
    const bindingValue = el.__bindingValue;
    const touchData = event.touches[0];

    //回调
    bindingValue.dragMove(event);

    const offsetX = touchData.clientX - dragData.startX;
    const offsetY = touchData.clientY - dragData.startY;
    let x = Math.floor(dragData.x + offsetX);
    let y = Math.floor(dragData.y - offsetY);
    [x, y] = getSafePosition(el, x, y);

    el.style.left = x + "px";
    el.style.bottom = y + "px";

    dragData.endX = x;
    dragData.endY = y;
    dragData.hasMove = true;
}

/** 触摸结束 */
function onTouchEnd(event: TouchEvent) {
    const el = event.currentTarget as DragElement;
    const dragData = el.__dragData;
    const bindingValue = el.__bindingValue;

    //回调
    bindingValue.dragEnd(event);

    if (!dragData.hasMove) return;
    dragData.startX = 0;
    dragData.startY = 0;
    dragData.hasMove = false;
    let [x, y] = getSafePosition(el, dragData.endX, dragData.endY);
    if (bindingValue.adsorptionX || bindingValue.adsorptionY) {
        [x, y] = getAdsorptionPosition(el, x, y);
    }
    dragData.x = x;
    dragData.y = y;

    el.style.left = x + "px";
    el.style.bottom = y + "px";

    dragData.hasMove = false;

    //持久化位置
    setLocalStoragePosition(bindingValue.storageKey, x, y);
}

const directive: Directive<DragElement, BindingValue> = {
    created(el, binding, vnode, prevVnode) {
        const { value } = binding;
        const bindingValue = Object.assign<DefaultBindingValue, BindingValue>(
            {
                offset: [0, 0],
                adsorptionX: false,
                adsorptionY: false,
                dragStart: () => {},
                dragMove: () => {},
                dragEnd: () => {}
            },
            value
        );
        //指令参数校验
        const [error, validate] = validateBindingValue(bindingValue);
        if (import.meta.env.VITE_ENV !== "development" && error) {
            console.error(error.message);
            return;
        } else {
            if (error) throw error;
        }
        //数据处理
        const dragData: DragData = {
            x: 0,
            y: 0,
            startX: 0,
            startY: 0,
            endX: 0,
            endY: 0,
            hasMove: false,
            init: false
        };
        el.__dragData = dragData;
        el.__bindingValue = bindingValue;

        //初始化位置
        initPosition(el);

        //绑定事件
        el.addEventListener("touchstart", onTouchStart);
        el.addEventListener("touchmove", onTouchMove);
        el.addEventListener("touchend", onTouchEnd);
    },
    beforeUnmount(el) {
        //删除数据
        Reflect.deleteProperty(el, "__dragData");
        Reflect.deleteProperty(el, "__bindingValue");
        //解绑事件
        el.removeEventListener("touchstart", onTouchStart);
        el.removeEventListener("touchmove", onTouchMove);
        el.removeEventListener("touchend", onTouchEnd);
    }
};

const dragDirective = {
    name: "drag",
    directive
};

export { dragDirective };

export default dragDirective;

具名和默认导出了dragDirective,然后去main.ts中激活指令即可。

import drag from "./directive/drag";

const app = createApp(App);
app.directive(drag.name, drag.directive);

使用时:

<div v-drag="{storageKey: 'module1'}"></div>

支持的参数

参考类型BindingValue

<div v-drag="{
  storageKey: 'module1',
  offset: [0, 0],
  adsorptionX: false,
  adsorptionY: false,
  dragStart: ()=> {},
  dragMove: ()=> {},
  dragEnd: ()=> {},
}"></div>

吸边的效果,一般情况下要么是X轴吸边,要么Y轴吸边,不可能同时两个轴都进行吸边,因为体验会很差,所以这里做了处理,如果xy同时为true的情况下,优先x轴吸边。

用户拖拽后肯定是需要记住拖拽后的位置,所以强制要求传入storageKey,如果你的项目中不需要记住,就自行调整下吧。

不知道为什么,自定义指令在实际使用的时候并没有被正确识别指令参数类型,所以我加了个参数校验,在开发者模式中直接就抛出错误,而在非开发者模式,仅打印错误并return,不处理任何拖拽逻辑。

拖拽核心逻辑

touchstart事件触发时,记录用户点击的位置,在touchmove的时候,计算移动时的位置与初始位置的差值,然后将差值与元素初始位置xy进行加减。

注意精华就是这个:在move时所有的移动位置计算,都是基于初始位置计算的,我一开始认为move时边移动,边计算与上一个移动位置的差距,导致计算非常复杂,效果还很差。

当用户touchend后,根据配置计算吸边,然后清空起始位置,缓存记录位置。

分类: vue 开发实战 标签: 拖拽自定义指令vue3offsetdirectivedrag吸边间距

评论

暂无评论数据

暂无评论数据

目录