float-add-btn 悬浮球支持屏幕内自由拖动与位置记忆

This commit is contained in:
owenchen committed 2026-10-14 16:10:00 +08:00
1 parent 4007871658
commit 3194f98ac0
1 file changed
+135 -5
@@ -1,10 +1,14 @@
<script setup lang="ts">
/**
* 悬浮操作按钮:右下角圆形蓝色渐变「+」,固定在底部导航之上。
* 悬浮操作按钮:蓝色渐变「+」,可在屏幕内自由拖动,松手后位置写入 Storage 下次启动恢复。
* 只做外观与点击上报;新建/上传等动作语义由页面处理。
* 拖动实现:touch 事件(真机/App)+ mouse 事件(H5 桌面调试)双轨;
* 位移小于阈值视为轻点(emit click),超过阈值才算拖动(touch 后屏蔽 mouse 兼容事件防双触发)。
*/
defineProps({
/** 额外底部偏移(rpx 数值),供不同页面避开各自的底部元素 */
import { computed, onMounted, ref } from "vue";
const props = defineProps({
/** 初始底部偏移(rpx 数值,仅首次无存储记录时生效),供不同页面避开各自的底部元素 */
bottomOffset: {
type: Number,
default: 0,
@@ -14,10 +18,132 @@ defineProps({
const emit = defineEmits<{
(e: "click"): void;
}>();
const STORAGE_KEY = "float-add-btn-pos";
/** 位移阈值(px):低于它按轻点处理 */
const TAP_THRESHOLD = 6;
const pos = ref({ x: 0, y: 0 });
/** 位置定妥前隐藏,避免默认位置 → 恢复位置的一帧闪跳 */
const ready = ref(false);
let dragging = false;
let moved = false;
let startPoint = { x: 0, y: 0 };
let originPos = { x: 0, y: 0 };
/** touch 已处理标记:移动端浏览器会在 touch 后补发 mouse 兼容事件,防双触发 */
let touchHandled = false;
function sizePx(): number {
return uni.upx2px(112);
}
/** 夹在屏幕可视区内,四边留 8px 呼吸位 */
function clampPos(x: number, y: number): { x: number; y: number } {
const { windowWidth, windowHeight } = uni.getSystemInfoSync();
const size = sizePx();
return {
x: Math.min(Math.max(8, x), windowWidth - size - 8),
y: Math.min(Math.max(8, y), windowHeight - size - 8),
};
}
onMounted(() => {
const { windowWidth, windowHeight } = uni.getSystemInfoSync();
const size = sizePx();
let x = windowWidth - size - 20;
let y = windowHeight - size - uni.upx2px(180 + props.bottomOffset);
const saved = uni.getStorageSync(STORAGE_KEY) as { x?: number; y?: number } | "";
if (saved && typeof saved.x === "number" && typeof saved.y === "number") {
x = saved.x;
y = saved.y;
}
pos.value = clampPos(x, y);
ready.value = true;
});
const posStyle = computed(() => ({
left: `${pos.value.x}px`,
top: `${pos.value.y}px`,
visibility: ready.value ? "visible" : "hidden",
}));
function onDragStart(x: number, y: number): void {
dragging = true;
moved = false;
startPoint = { x, y };
originPos = { ...pos.value };
}
function onDragMove(x: number, y: number): void {
if (!dragging) {
return;
}
const dx = x - startPoint.x;
const dy = y - startPoint.y;
if (!moved && Math.abs(dx) < TAP_THRESHOLD && Math.abs(dy) < TAP_THRESHOLD) {
return;
}
moved = true;
pos.value = clampPos(originPos.x + dx, originPos.y + dy);
}
function onDragEnd(): void {
if (!dragging) {
return;
}
dragging = false;
if (moved) {
uni.setStorageSync(STORAGE_KEY, pos.value);
}
else {
emit("click");
}
}
// ===== touch(真机 / App)=====
function onTouchStart(e: TouchEvent): void {
touchHandled = true;
const touch = e.touches[0];
onDragStart(touch.clientX, touch.clientY);
}
function onTouchMove(e: TouchEvent): void {
const touch = e.touches[0];
onDragMove(touch.clientX, touch.clientY);
}
function onTouchEnd(): void {
onDragEnd();
// 下一个宏任务复位,让补发的 mouse 兼容事件先被拦截
setTimeout(() => {
touchHandled = false;
}, 0);
}
// ===== mouse(H5 桌面)=====
function onMouseDown(e: MouseEvent): void {
if (touchHandled) {
return;
}
e.preventDefault();
onDragStart(e.clientX, e.clientY);
const move = (ev: MouseEvent) => onDragMove(ev.clientX, ev.clientY);
const up = () => {
onDragEnd();
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
</script>
<template>
<view class="float-add-btn" :style="{ bottom: `${180 + bottomOffset}rpx` }" @tap="emit('click')">
<view class="float-add-btn" :style="posStyle" @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd" @mousedown="onMouseDown">
<u-icon name="plus" size="52" color="#ffffff" />
</view>
</template>
@@ -25,7 +151,8 @@ const emit = defineEmits<{
<style lang="scss" scoped>
.float-add-btn {
position: fixed;
right: 40rpx;
top: 0;
left: 0;
z-index: 8;
display: flex;
align-items: center;
@@ -35,5 +162,8 @@ const emit = defineEmits<{
border-radius: 50%;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 12rpx 32rpx rgba(43, 143, 255, 0.4);
touch-action: none;
/* 屏蔽浏览器默认滚动手势,touchmove 才不被吞 */
user-select: none;
}
</style>