cl-float-view.uvue 6.4 KB

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