cl-float-view.uvue 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. <template>
  2. <view
  3. class="cl-float-view"
  4. :class="{
  5. 'no-dragging': !position.isDragging
  6. }"
  7. :style="viewStyle"
  8. @touchstart="onTouchStart"
  9. @touchmove.stop.prevent="onTouchMove"
  10. @touchend="onTouchEnd"
  11. @touchcancel="onTouchEnd"
  12. >
  13. <slot></slot>
  14. </view>
  15. </template>
  16. <script setup lang="ts">
  17. import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, router } from "@/cool";
  18. import { computed, reactive } from "vue";
  19. import { clFooterOffset } from "../cl-footer/offset";
  20. defineOptions({
  21. name: "cl-float-view"
  22. });
  23. const props = defineProps({
  24. // 图层
  25. zIndex: {
  26. type: Number,
  27. default: 500
  28. },
  29. // 尺寸
  30. size: {
  31. type: Number,
  32. default: 40
  33. },
  34. // 左边距
  35. left: {
  36. type: Number,
  37. default: 10
  38. },
  39. // 底部距离
  40. bottom: {
  41. type: Number,
  42. default: 10
  43. },
  44. // 距离边缘的间距
  45. gap: {
  46. type: Number,
  47. default: 10
  48. },
  49. // 是否禁用
  50. disabled: {
  51. type: Boolean,
  52. default: false
  53. },
  54. // 不吸附边缘
  55. noSnapping: {
  56. type: Boolean,
  57. default: false
  58. }
  59. });
  60. // 获取设备屏幕信息
  61. const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo();
  62. /**
  63. * 悬浮按钮位置状态类型定义
  64. */
  65. type Position = {
  66. x: number; // 水平位置(左边距)
  67. y: number; // 垂直位置(相对底部的距离)
  68. isDragging: boolean; // 是否正在拖拽中
  69. };
  70. /**
  71. * 悬浮按钮位置状态管理
  72. * 控制按钮在屏幕上的位置和拖拽状态
  73. */
  74. const position = reactive<Position>({
  75. x: props.left, // 初始左边距10px
  76. y: props.bottom, // 初始距离底部10px
  77. isDragging: false // 初始状态为非拖拽
  78. });
  79. /**
  80. * 拖拽操作状态类型定义
  81. */
  82. type DragState = {
  83. startX: number; // 拖拽开始时的X坐标
  84. startY: number; // 拖拽开始时的Y坐标
  85. };
  86. /**
  87. * 拖拽操作状态管理
  88. * 记录拖拽过程中的关键信息
  89. */
  90. const dragState = reactive<DragState>({
  91. startX: 0,
  92. startY: 0
  93. });
  94. /**
  95. * 动态位置样式计算
  96. * 根据当前位置和拖拽状态计算组件的CSS样式
  97. */
  98. const viewStyle = computed(() => {
  99. const style = {};
  100. // 额外的底部偏移
  101. let bottomOffset = 0;
  102. // 标签页需要额外减去标签栏高度和安全区域
  103. if (hasCustomTabBar()) {
  104. bottomOffset += getTabBarHeight();
  105. } else {
  106. // 获取其他组件注入的底部偏移
  107. bottomOffset += clFooterOffset.get();
  108. }
  109. // 设置水平位置
  110. style["left"] = `${position.x}px`;
  111. // 设置垂直位置(从底部计算)
  112. style["bottom"] = `${bottomOffset + position.y}px`;
  113. // 设置z-index
  114. style["z-index"] = props.zIndex;
  115. // 设置尺寸
  116. style["width"] = `${props.size}px`;
  117. // 设置高度
  118. style["height"] = `${props.size}px`;
  119. return style;
  120. });
  121. /**
  122. * 计算垂直方向的边界限制
  123. * @returns 返回最大Y坐标值(距离底部的最大距离)
  124. */
  125. function calculateMaxY(): number {
  126. let maxY = screenHeight - props.size;
  127. // 根据导航栏状态调整顶部边界
  128. if (router.isCustomNavbarPage()) {
  129. // 自定义导航栏页面,只需减去状态栏高度
  130. maxY -= statusBarHeight;
  131. } else {
  132. // 默认导航栏页面,减去导航栏高度(44px)和状态栏高度
  133. maxY -= 44 + statusBarHeight;
  134. }
  135. // 标签页需要额外减去标签栏高度和安全区域
  136. if (router.isTabPage()) {
  137. maxY -= getTabBarHeight();
  138. }
  139. return maxY;
  140. }
  141. // 计算垂直边界
  142. const maxY = calculateMaxY();
  143. /**
  144. * 触摸开始事件处理
  145. * 初始化拖拽状态,记录起始位置和时间
  146. */
  147. function onTouchStart(e: TouchEvent) {
  148. // 如果禁用,直接返回
  149. if (props.disabled) return;
  150. // 确保有触摸点存在
  151. if (e.touches.length > 0) {
  152. const touch = e.touches[0];
  153. // 记录拖拽开始的位置
  154. dragState.startX = touch.clientX;
  155. dragState.startY = touch.clientY;
  156. // 标记为拖拽状态,关闭过渡动画
  157. position.isDragging = true;
  158. }
  159. }
  160. /**
  161. * 触摸移动事件处理
  162. * 实时更新按钮位置,实现拖拽效果
  163. */
  164. function onTouchMove(e: TouchEvent) {
  165. // 如果不在拖拽状态或没有触摸点,直接返回
  166. if (!position.isDragging || e.touches.length == 0) return;
  167. // 阻止默认的滚动行为
  168. e.preventDefault();
  169. const touch = e.touches[0];
  170. // 计算相对于起始位置的偏移量
  171. const deltaX = touch.clientX - dragState.startX;
  172. const deltaY = dragState.startY - touch.clientY; // Y轴方向相反(屏幕坐标系向下为正,我们的bottom向上为正)
  173. // 计算新的位置
  174. let newX = position.x + deltaX;
  175. let newY = position.y + deltaY;
  176. // 水平方向边界限制:确保按钮不超出屏幕左右边界
  177. newX = Math.max(0, Math.min(screenWidth - props.size, newX));
  178. // 垂直方向边界限制
  179. let minY = 0;
  180. // 非标签页时,底部需要考虑安全区域
  181. if (!router.isTabPage()) {
  182. minY += getSafeAreaHeight("bottom");
  183. }
  184. // 确保按钮不超出屏幕上下边界
  185. newY = Math.max(minY, Math.min(maxY, newY));
  186. // 更新按钮位置
  187. position.x = newX;
  188. position.y = newY;
  189. // 更新拖拽起始点,为下次移动计算做准备
  190. dragState.startX = touch.clientX;
  191. dragState.startY = touch.clientY;
  192. }
  193. /**
  194. * 执行边缘吸附逻辑
  195. * 拖拽结束后自动将按钮吸附到屏幕边缘
  196. */
  197. function performEdgeSnapping() {
  198. const edgeThreshold = 60; // 吸附触发阈值(像素)
  199. const edgePadding = props.gap; // 距离边缘的间距
  200. // 判断按钮当前更靠近左边还是右边
  201. const centerX = screenWidth / 2;
  202. const isLeftSide = position.x < centerX;
  203. // 水平方向吸附逻辑
  204. if (position.x < edgeThreshold) {
  205. // 距离左边缘很近,吸附到左边
  206. position.x = edgePadding;
  207. } else if (position.x > screenWidth - props.size - edgeThreshold) {
  208. // 距离右边缘很近,吸附到右边
  209. position.x = screenWidth - props.size - edgePadding;
  210. } else if (isLeftSide) {
  211. // 在左半屏且不在边缘阈值内,吸附到左边
  212. position.x = edgePadding;
  213. } else {
  214. // 在右半屏且不在边缘阈值内,吸附到右边
  215. position.x = screenWidth - props.size - edgePadding;
  216. }
  217. // 垂直方向边界修正
  218. const verticalPadding = props.gap;
  219. if (position.y > maxY - verticalPadding) {
  220. position.y = maxY - verticalPadding;
  221. }
  222. if (position.y < verticalPadding) {
  223. position.y = verticalPadding;
  224. }
  225. }
  226. /**
  227. * 触摸结束事件处理
  228. * 结束拖拽状态并执行边缘吸附
  229. */
  230. function onTouchEnd() {
  231. // 如果不在拖拽状态,直接返回
  232. if (!position.isDragging) return;
  233. // 结束拖拽状态,恢复过渡动画
  234. position.isDragging = false;
  235. // 执行边缘吸附逻辑
  236. if (!props.noSnapping) {
  237. performEdgeSnapping();
  238. }
  239. }
  240. </script>
  241. <style lang="scss" scoped>
  242. .cl-float-view {
  243. @apply fixed transition-none;
  244. &.no-dragging {
  245. @apply duration-300;
  246. transition-property: left, bottom;
  247. }
  248. }
  249. </style>