| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- <template>
- <view
- class="cl-float-view"
- :class="{
- 'no-dragging': !position.isDragging
- }"
- :style="viewStyle"
- @touchstart="onTouchStart"
- @touchmove.stop.prevent="onTouchMove"
- @touchend="onTouchEnd"
- @touchcancel="onTouchEnd"
- >
- <slot></slot>
- </view>
- </template>
- <script setup lang="ts">
- import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, router } from "@/cool";
- import { computed, reactive } from "vue";
- import { clFooterOffset } from "../cl-footer/offset";
- 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();
- /**
- * 悬浮按钮位置状态类型定义
- */
- 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(() => {
- const style = {};
- // 额外的底部偏移
- let bottomOffset = 0;
- // 标签页需要额外减去标签栏高度和安全区域
- if (hasCustomTabBar()) {
- bottomOffset += getTabBarHeight();
- } else {
- // 获取其他组件注入的底部偏移
- bottomOffset += clFooterOffset.get();
- }
- // 设置水平位置
- style["left"] = `${position.x}px`;
- // 设置垂直位置(从底部计算)
- style["bottom"] = `${bottomOffset + position.y}px`;
- // 设置z-index
- style["z-index"] = props.zIndex;
- // 设置尺寸
- style["width"] = `${props.size}px`;
- // 设置高度
- style["height"] = `${props.size}px`;
- 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 -= 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 += 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 transition-none;
- &.no-dragging {
- @apply duration-300;
- transition-property: left, bottom;
- }
- }
- </style>
|