cl-float-view.uvue 6.9 KB

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