cl-float-view.uvue 6.4 KB

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