uniapp 可拖动悬浮按钮

2,021 阅读3分钟

使用场景及功能

在项目中会出现需要在页面右下角或者其他地方放一个悬浮按钮的功能

比如:新增订单、联系客服等等

但是这个按钮可能会挡住页面的内容,所以就出现了封装这个组件的想法

功能:

  • 实现多端(小程序、APP、H5)的拖动按钮功能
  • 提供默认插槽让使用者自定义按钮样式以及交互逻辑

效果:

PixPin_2025-04-21_09-03-33.webp

参数

参数名描述示例
left按钮初始位置:距离屏幕左侧的距离number:50
right按钮初始位置:距离屏幕右侧的距离number:50
top按钮初始位置:距离屏幕顶部的距离number:50
bottom按钮初始位置:距离屏幕底部的距离number:50
zIndex按钮的层级number:9999
isDrag是否启用拖拽Boolean:true
isAdsorption是否启用吸附功能Boolean:true
adsorptionThreshold吸附的阈值number:10

参数详细说明

  • 参数所有的单位都是px
  • 吸附功能:按钮移动到设备边缘时,按钮会隐藏一半宽度/高度到边界内,就像是边界把按钮吸了一半进去
  • 吸附阈值:按钮距离边缘多远会被吸附

代码

<template>
	<div
		class="drag-button"
		@touchstart="touchStart"
		@touchmove.prevent="touchMove"
		@touchend="touchEnd"
		:class="{ 'is-dragging': isDragging }"
		:style="{
			transform: `translate3d(${buttonLeft}px, ${buttonTop}px, 0)`,
			opacity: isInit ? 1 : 0,
			zIndex: props.zIndex,
		}"
		ref="buttonRef"
	>
		<slot>
			<!-- 默认插槽内容 -->
			<div class="drag-button__default"> 按钮 </div>
		</slot>
	</div>
</template>

<script setup>
	import { ref, onMounted, nextTick, getCurrentInstance } from 'vue';
	const { proxy } = getCurrentInstance();

	// 定义组件属性
	const props = defineProps({
		left: {
			type: Number,
			default: null,
		},
		right: {
			type: Number,
			default: 20,
		},
		top: {
			type: Number,
			default: null,
		},
		bottom: {
			type: Number,
			default: 20,
		},
		zIndex: {
			type: Number,
			default: 9999,
		},
		isDrag: {
			type: Boolean,
			default: true,
		},
		isAdsorption: {
			type: Boolean,
			default: true,
		},
		adsorptionThreshold: {
			type: Number,
			default: 10,
			validator: (value) => value >= 0,
		},
	});

	onMounted(async () => {
		await getButtonSize();
		calculateInitialPosition();
		await new Promise((resolve) => setTimeout(resolve, 300));
		isInit.value = true;
	});

	// 按钮位置状态
	const buttonTop = ref(0);
	const buttonLeft = ref(0);
	const startX = ref(0);
	const startY = ref(0);
	const buttonRef = ref(null);
	const buttonSize = ref({ width: 0, height: 0 });
	const isDragging = ref(false);
	const isInit = ref(false);

	// 获取按钮实际尺寸
	const getButtonSize = async () => {
		await new Promise((resolve) => setTimeout(resolve, 150));
		await nextTick();
		// #ifdef H5
		buttonSize.value = {
			width: buttonRef.value.offsetWidth,
			height: buttonRef.value.offsetHeight,
		};
		// #endif

		// #ifdef MP || APP-PLUS
		return new Promise((resolve) => {
			const query = uni.createSelectorQuery().in(proxy);
			query
				.select('.drag-button')
				.boundingClientRect((data) => {
					if (data) {
						buttonSize.value = {
							width: data.width,
							height: data.height,
						};
						resolve();
					}
				})
				.exec();
		});
		// #endif
	};

	// 计算初始位置
	const calculateInitialPosition = () => {
		const systemInfo = uni.getWindowInfo();
		const windowWidth = systemInfo.windowWidth;
		const windowHeight = systemInfo.windowHeight;

		// 水平位置计算
		if (props.left !== null) {
			// 左侧定位,按钮左边缘距离左边界指定距离
			buttonLeft.value = props.left;
		} else if (props.right !== null) {
			// 右侧定位,按钮右边缘距离右边界指定距离
			buttonLeft.value = windowWidth - buttonSize.value.width - props.right;
		}

		// 垂直位置计算
		if (props.top !== null) {
			// 顶部定位,按钮顶边距离顶部边界指定距离
			buttonTop.value = props.top;
		} else if (props.bottom !== null) {
			// 底部定位,按钮底边距离底部边界指定距离
			buttonTop.value = windowHeight - buttonSize.value.height - props.bottom;
		}
	};

	// 计算拖动后的位置
	const calculateDragPosition = (clientX, clientY) => {
		const systemInfo = uni.getWindowInfo();
		const windowWidth = systemInfo.windowWidth;
		const windowHeight = systemInfo.windowHeight;

		// 直接计算新位置,不需要考虑与边界的最小距离
		let newLeft = clientX - startX.value;
		let newTop = clientY - startY.value;

		// 只限制不超出屏幕边界
		newLeft = Math.max(
			0,
			Math.min(newLeft, windowWidth - buttonSize.value.width)
		);
		newTop = Math.max(
			0,
			Math.min(newTop, windowHeight - buttonSize.value.height)
		);

		return { newLeft, newTop };
	};

	// 触摸开始事件
	const touchStart = (e) => {
		if (!props.isDrag) return;
		isDragging.value = true;
		const touch = e.touches[0] || e.changedTouches[0];
		startX.value = touch.clientX - buttonLeft.value;
		startY.value = touch.clientY - buttonTop.value;
	};

	// 触摸移动事件
	const touchMove = (e) => {
		if (!props.isDrag) return;
		e.preventDefault && e.preventDefault();
		const touch = e.touches[0] || e.changedTouches[0];
		const { newLeft, newTop } = calculateDragPosition(
			touch.clientX,
			touch.clientY
		);

		buttonLeft.value = newLeft;
		buttonTop.value = newTop;
	};

	// 触摸结束事件
	const touchEnd = () => {
		if (!props.isDrag || !props.isAdsorption) return;
		isDragging.value = false;

		const systemInfo = uni.getWindowInfo();
		const windowWidth = systemInfo.windowWidth;
		const windowHeight = systemInfo.windowHeight;

		// 计算按钮到四个边缘的距离
		const distanceToLeft = buttonLeft.value;
		const distanceToRight =
			windowWidth - (buttonLeft.value + buttonSize.value.width);
		const distanceToTop = buttonTop.value;
		const distanceToBottom =
			windowHeight - (buttonTop.value + buttonSize.value.height);

		// 水平方向吸附(露出一半按钮宽度)
		if (distanceToLeft < props.adsorptionThreshold) {
			buttonLeft.value = -buttonSize.value.width / 2; // 左侧吸附,露出右半部分
			// console.log('吸附左边');
		} else if (distanceToRight < props.adsorptionThreshold) {
			buttonLeft.value = windowWidth - buttonSize.value.width / 2; // 右侧吸附,露出左半部分
			// console.log('吸附右边');
		}

		// 垂直方向吸附(露出一半按钮高度)
		if (distanceToTop < props.adsorptionThreshold) {
			buttonTop.value = -buttonSize.value.height / 2; // 顶部吸附,露出下半部分
			// console.log('吸附上边');
		} else if (distanceToBottom < props.adsorptionThreshold) {
			buttonTop.value = windowHeight - buttonSize.value.height / 2; // 底部吸附,露出上半部分
			// console.log('吸附下边');
		}
	};
</script>

<style lang="scss" scoped>
	.drag-button {
		position: fixed;
		left: 0;
		top: 0;
		display: inline-flex;
		justify-content: center;
		align-items: center;
		will-change: transform;
		touch-action: none;
		transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);

		&.is-dragging {
			transition: none; // 拖动时禁用过渡动画
		}

		&__default {
			width: 50px;
			height: 50px;
			background: #000;
			border-radius: 50%;
			color: #fff;
			font-size: 12px;
			font-weight: bold;
			display: flex;
			justify-content: center;
			align-items: center;
		}
	}
</style>

页面使用

<!-- 右下角,以按钮右下角为原点 -->
	<dragButton :right="20" :bottom="20"> </dragButton>

	<!-- 左上角,以按钮左上角为原点 -->
	<dragButton :left="20" :top="20">
		<div class="btn">
			<text>左上</text>
		</div>
	</dragButton>

	<!-- 右上角,以按钮右上角为原点 -->
	<dragButton :right="20" :top="20">
		<div class="btn">
			<text>右上</text>
		</div>
	</dragButton>

	<!-- 左下角,以按钮左下角为原点 -->
	<dragButton :left="20" :bottom="20">
		<div class="btn">
			<text>左下</text>
		</div>
	</dragButton>
        
        import dragButton from './dragButton.vue';