vue3 自定义移动端拖拽指令(支持吸边和offset间距以及回调)
前言
移动端开发的时候,总是会遇到编写全局浮动组件的情况,比如客服图标啦,消息通知啦,但是浮动就会产生一个问题:阻挡了下层内容展示,常见的做法就是让这个浮动的内容支持拖拽功能,用户将浮动组件拖拽到其他位置即可。
这里我参考了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后,根据配置计算吸边,然后清空起始位置,缓存记录位置。
版权申明
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据