2025-07-21 16:47:04 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<view
|
|
|
|
|
|
class="cl-float-view"
|
|
|
|
|
|
:style="viewStyle"
|
|
|
|
|
|
@touchstart="onTouchStart"
|
|
|
|
|
|
@touchmove.stop.prevent="onTouchMove"
|
|
|
|
|
|
@touchend="onTouchEnd"
|
|
|
|
|
|
@touchcancel="onTouchEnd"
|
|
|
|
|
|
>
|
|
|
|
|
|
<slot></slot>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { router, usePage } from "@/cool";
|
|
|
|
|
|
import { computed, reactive } from "vue";
|
|
|
|
|
|
|
|
|
|
|
|
defineOptions({
|
|
|
|
|
|
name: "cl-float-view"
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
// 图层
|
|
|
|
|
|
zIndex: {
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
default: 500
|
|
|
|
|
|
},
|
|
|
|
|
|
// 尺寸
|
|
|
|
|
|
size: {
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
default: 40
|
|
|
|
|
|
},
|
|
|
|
|
|
// 左边距
|
|
|
|
|
|
left: {
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
default: 10
|
|
|
|
|
|
},
|
|
|
|
|
|
// 底部距离
|
|
|
|
|
|
bottom: {
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
default: 10
|
|
|
|
|
|
},
|
|
|
|
|
|
// 距离边缘的间距
|
|
|
|
|
|
gap: {
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
default: 10
|
|
|
|
|
|
},
|
|
|
|
|
|
// 是否禁用
|
|
|
|
|
|
disabled: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
|
|
|
|
|
},
|
|
|
|
|
|
// 不吸附边缘
|
|
|
|
|
|
noSnapping: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 获取设备屏幕信息
|
|
|
|
|
|
const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo();
|
|
|
|
|
|
|
|
|
|
|
|
// 页面实例
|
|
|
|
|
|
const page = usePage();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 悬浮按钮位置状态类型定义
|
|
|
|
|
|
*/
|
|
|
|
|
|
type Position = {
|
|
|
|
|
|
x: number; // 水平位置(左边距)
|
|
|
|
|
|
y: number; // 垂直位置(相对底部的距离)
|
|
|
|
|
|
isDragging: boolean; // 是否正在拖拽中
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 悬浮按钮位置状态管理
|
|
|
|
|
|
* 控制按钮在屏幕上的位置和拖拽状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
const position = reactive<Position>({
|
|
|
|
|
|
x: props.left, // 初始左边距10px
|
|
|
|
|
|
y: props.bottom, // 初始距离底部10px
|
|
|
|
|
|
isDragging: false // 初始状态为非拖拽
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 拖拽操作状态类型定义
|
|
|
|
|
|
*/
|
|
|
|
|
|
type DragState = {
|
|
|
|
|
|
startX: number; // 拖拽开始时的X坐标
|
|
|
|
|
|
startY: number; // 拖拽开始时的Y坐标
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 拖拽操作状态管理
|
|
|
|
|
|
* 记录拖拽过程中的关键信息
|
|
|
|
|
|
*/
|
|
|
|
|
|
const dragState = reactive<DragState>({
|
|
|
|
|
|
startX: 0,
|
|
|
|
|
|
startY: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 动态位置样式计算
|
|
|
|
|
|
* 根据当前位置和拖拽状态计算组件的CSS样式
|
|
|
|
|
|
*/
|
|
|
|
|
|
const viewStyle = computed(() => {
|
2025-07-29 22:30:23 +08:00
|
|
|
|
const style = {};
|
2025-07-21 16:47:04 +08:00
|
|
|
|
|
|
|
|
|
|
// 额外的底部偏移
|
|
|
|
|
|
let bottomOffset = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 标签页需要额外减去标签栏高度和安全区域
|
|
|
|
|
|
if (page.hasCustomTabBar()) {
|
|
|
|
|
|
bottomOffset += page.getTabBarHeight();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置水平位置
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["left"] = `${position.x}px`;
|
2025-07-21 16:47:04 +08:00
|
|
|
|
// 设置垂直位置(从底部计算)
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["bottom"] = `${bottomOffset + position.y}px`;
|
2025-07-21 16:47:04 +08:00
|
|
|
|
// 设置z-index
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["z-index"] = props.zIndex;
|
2025-07-21 16:47:04 +08:00
|
|
|
|
// 设置尺寸
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["width"] = `${props.size}px`;
|
2025-07-21 16:47:04 +08:00
|
|
|
|
// 设置高度
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["height"] = `${props.size}px`;
|
2025-07-21 16:47:04 +08:00
|
|
|
|
|
|
|
|
|
|
// 非拖拽状态下添加过渡动画,使移动更平滑
|
|
|
|
|
|
if (!position.isDragging) {
|
2025-07-29 22:30:23 +08:00
|
|
|
|
style["transition-duration"] = "300ms";
|
2025-07-21 16:47:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return style;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 计算垂直方向的边界限制
|
|
|
|
|
|
* @returns 返回最大Y坐标值(距离底部的最大距离)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function calculateMaxY(): number {
|
|
|
|
|
|
let maxY = screenHeight - props.size;
|
|
|
|
|
|
|
|
|
|
|
|
// 根据导航栏状态调整顶部边界
|
|
|
|
|
|
if (router.isCustomNavbarPage()) {
|
|
|
|
|
|
// 自定义导航栏页面,只需减去状态栏高度
|
|
|
|
|
|
maxY -= statusBarHeight;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 默认导航栏页面,减去导航栏高度(44px)和状态栏高度
|
|
|
|
|
|
maxY -= 44 + statusBarHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标签页需要额外减去标签栏高度和安全区域
|
|
|
|
|
|
if (router.isTabPage()) {
|
|
|
|
|
|
maxY -= page.getTabBarHeight();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return maxY;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算垂直边界
|
|
|
|
|
|
const maxY = calculateMaxY();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 触摸开始事件处理
|
|
|
|
|
|
* 初始化拖拽状态,记录起始位置和时间
|
|
|
|
|
|
*/
|
|
|
|
|
|
function onTouchStart(e: TouchEvent) {
|
|
|
|
|
|
// 如果禁用,直接返回
|
|
|
|
|
|
if (props.disabled) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 确保有触摸点存在
|
|
|
|
|
|
if (e.touches.length > 0) {
|
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
|
// 记录拖拽开始的位置
|
|
|
|
|
|
dragState.startX = touch.clientX;
|
|
|
|
|
|
dragState.startY = touch.clientY;
|
|
|
|
|
|
// 标记为拖拽状态,关闭过渡动画
|
|
|
|
|
|
position.isDragging = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 触摸移动事件处理
|
|
|
|
|
|
* 实时更新按钮位置,实现拖拽效果
|
|
|
|
|
|
*/
|
|
|
|
|
|
function onTouchMove(e: TouchEvent) {
|
|
|
|
|
|
// 如果不在拖拽状态或没有触摸点,直接返回
|
|
|
|
|
|
if (!position.isDragging || e.touches.length == 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 阻止默认的滚动行为
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const touch = e.touches[0];
|
|
|
|
|
|
// 计算相对于起始位置的偏移量
|
|
|
|
|
|
const deltaX = touch.clientX - dragState.startX;
|
|
|
|
|
|
const deltaY = dragState.startY - touch.clientY; // Y轴方向相反(屏幕坐标系向下为正,我们的bottom向上为正)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算新的位置
|
|
|
|
|
|
let newX = position.x + deltaX;
|
|
|
|
|
|
let newY = position.y + deltaY;
|
|
|
|
|
|
|
|
|
|
|
|
// 水平方向边界限制:确保按钮不超出屏幕左右边界
|
|
|
|
|
|
newX = Math.max(0, Math.min(screenWidth - props.size, newX));
|
|
|
|
|
|
|
|
|
|
|
|
// 垂直方向边界限制
|
|
|
|
|
|
let minY = 0;
|
|
|
|
|
|
// 非标签页时,底部需要考虑安全区域
|
|
|
|
|
|
if (!router.isTabPage()) {
|
|
|
|
|
|
minY += page.getSafeAreaHeight("bottom");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确保按钮不超出屏幕上下边界
|
|
|
|
|
|
newY = Math.max(minY, Math.min(maxY, newY));
|
|
|
|
|
|
|
|
|
|
|
|
// 更新按钮位置
|
|
|
|
|
|
position.x = newX;
|
|
|
|
|
|
position.y = newY;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新拖拽起始点,为下次移动计算做准备
|
|
|
|
|
|
dragState.startX = touch.clientX;
|
|
|
|
|
|
dragState.startY = touch.clientY;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 执行边缘吸附逻辑
|
|
|
|
|
|
* 拖拽结束后自动将按钮吸附到屏幕边缘
|
|
|
|
|
|
*/
|
|
|
|
|
|
function performEdgeSnapping() {
|
|
|
|
|
|
const edgeThreshold = 60; // 吸附触发阈值(像素)
|
|
|
|
|
|
const edgePadding = props.gap; // 距离边缘的间距
|
|
|
|
|
|
|
|
|
|
|
|
// 判断按钮当前更靠近左边还是右边
|
|
|
|
|
|
const centerX = screenWidth / 2;
|
|
|
|
|
|
const isLeftSide = position.x < centerX;
|
|
|
|
|
|
|
|
|
|
|
|
// 水平方向吸附逻辑
|
|
|
|
|
|
if (position.x < edgeThreshold) {
|
|
|
|
|
|
// 距离左边缘很近,吸附到左边
|
|
|
|
|
|
position.x = edgePadding;
|
|
|
|
|
|
} else if (position.x > screenWidth - props.size - edgeThreshold) {
|
|
|
|
|
|
// 距离右边缘很近,吸附到右边
|
|
|
|
|
|
position.x = screenWidth - props.size - edgePadding;
|
|
|
|
|
|
} else if (isLeftSide) {
|
|
|
|
|
|
// 在左半屏且不在边缘阈值内,吸附到左边
|
|
|
|
|
|
position.x = edgePadding;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 在右半屏且不在边缘阈值内,吸附到右边
|
|
|
|
|
|
position.x = screenWidth - props.size - edgePadding;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 垂直方向边界修正
|
|
|
|
|
|
const verticalPadding = props.gap;
|
|
|
|
|
|
if (position.y > maxY - verticalPadding) {
|
|
|
|
|
|
position.y = maxY - verticalPadding;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (position.y < verticalPadding) {
|
|
|
|
|
|
position.y = verticalPadding;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 触摸结束事件处理
|
|
|
|
|
|
* 结束拖拽状态并执行边缘吸附
|
|
|
|
|
|
*/
|
|
|
|
|
|
function onTouchEnd() {
|
|
|
|
|
|
// 如果不在拖拽状态,直接返回
|
|
|
|
|
|
if (!position.isDragging) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 结束拖拽状态,恢复过渡动画
|
|
|
|
|
|
position.isDragging = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 执行边缘吸附逻辑
|
|
|
|
|
|
if (!props.noSnapping) {
|
|
|
|
|
|
performEdgeSnapping();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.cl-float-view {
|
|
|
|
|
|
@apply fixed;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|