u-fab.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. <template>
  2. <view
  3. @touchmove.stop.prevent="handleTouchMove"
  4. @touchstart="handleTouchStart"
  5. @touchend="handleTouchEnd"
  6. class="u-fab"
  7. :class="[`u-fab--${this.position}`]"
  8. :style="[rootStyle]"
  9. @click.stop=""
  10. >
  11. <view :style="{ visibility: inited ? 'visible' : 'hidden' }" class="u-fab__trigger-wrapper">
  12. <slot name="trigger" v-if="$slots.trigger || $slots.$trigger"></slot>
  13. <u-button
  14. v-else
  15. shape="circle"
  16. @click="handleClick"
  17. :type="type"
  18. :disabled="disabled"
  19. :custom-style="triggerStyle"
  20. :icon="isActive ? activeIcon : inactiveIcon"
  21. ></u-button>
  22. </view>
  23. <u-transition
  24. v-if="expandable"
  25. :show="isActive"
  26. mode="fade"
  27. :duration="300"
  28. :custom-style="actionsStyle"
  29. >
  30. <view class="u-fab__actions" :class="[`u-fab__actions--${this.fabDirection}`]">
  31. <slot></slot>
  32. </view>
  33. </u-transition>
  34. </view>
  35. </template>
  36. <script>
  37. // #ifdef APP-NVUE
  38. const dom = uni.requireNativePlugin('dom')
  39. // #endif
  40. import props from './props.js';
  41. import mixin from '../../libs/mixin/mixin'
  42. import mpMixin from '../../libs/mixin/mpMixin';
  43. /**
  44. * fab 悬浮按钮
  45. * @description 悬浮动作按钮组件,按下可显示一组动作按钮
  46. * @tutorial https://uview.d3u.cn/components/fab.html
  47. * @property {Boolean} active 是否激活 (默认 false )
  48. * @property {String} type 类型,可选值为 primary、success、info、warning、error、default (默认 'primary' )
  49. * @property {String} position 悬浮按钮位置,可选值为 left-top、right-top、left-bottom、right-bottom、left-center、right-center、top-center、bottom-center (默认 'right-bottom' )
  50. * @property {String} draggable 按钮拖动模式,可选值为 auto(自动吸附)、free(自由拖动)、none(不可拖动) (默认 'auto' )
  51. * @property {String} direction 悬浮按钮菜单弹出方向,可选值为 top、right、bottom、left (默认 'top' )
  52. * @property {Boolean} disabled 是否禁用 (默认 false )
  53. * @property {String} inactiveIcon 悬浮按钮未展开时的图标 (默认 'plus' )
  54. * @property {String} activeIcon 悬浮按钮展开时的图标 (默认 'close' )
  55. * @property {String} iconColor 悬浮按钮图标颜色 (默认 '#fff' )
  56. * @property {Number} zIndex 自定义悬浮按钮层级 (默认 99 )
  57. * @property {Object} gap 自定义悬浮按钮与可视区域边缘的间距 (默认 {top: 16, left: 16, right: 16, bottom: 16} )
  58. * @property {Boolean} expandable 用于控制点击时是否展开菜单 (默认 true )
  59. * @property {Object} customStyle 定义需要用到的外部样式
  60. *
  61. * @event {Function} click expandable 设置为 false 时,点击悬浮按钮触发
  62. * @event {Function} change 菜单状态改变时触发
  63. * @event {Function} update:active 激活状态改变时触发
  64. * @example <u-fab v-model:active="active" :type="type" :position="position" :direction="direction"></u-fab>
  65. */
  66. export default {
  67. name: "u-fab",
  68. mixins: [mpMixin, mixin, props],
  69. data() {
  70. return {
  71. inited: false, // 是否初始化完成
  72. isActive: false, // 是否激活状态
  73. fabDirection: this.direction, // 实际弹出方向
  74. top: 0,
  75. left: 0,
  76. screen: { width: 0, height: 0 },
  77. fabSize: { width: 56, height: 56 },
  78. bounding: {
  79. minTop: 0,
  80. minLeft: 0,
  81. maxTop: 0,
  82. maxLeft: 0
  83. },
  84. touchOffset: { x: 0, y: 0 }, // 按下时坐标相对于元素的偏移量
  85. attractTransition: false // 是否显示吸附动画
  86. }
  87. },
  88. computed: {
  89. rootStyle() {
  90. const style = {
  91. position: 'fixed',
  92. top: this.$u.addUnit(this.top),
  93. left: this.$u.addUnit(this.left),
  94. zIndex: this.zIndex,
  95. transition: this.attractTransition ? 'all ease 0.3s' : 'none'
  96. }
  97. return uni.$u.deepMerge(style, uni.$u.addStyle(this.customStyle))
  98. },
  99. triggerStyle() {
  100. return {
  101. width: this.$u.addUnit(this.size),
  102. height: this.$u.addUnit(this.size),
  103. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)'
  104. }
  105. },
  106. actionsStyle() {
  107. const style = {
  108. position: 'absolute',
  109. zIndex: 1,
  110. display: 'flex',
  111. alignItems: 'center'
  112. }
  113. switch (this.fabDirection) {
  114. case 'top':
  115. style.bottom = '100%';
  116. style.left = '50%';
  117. style.transform = 'translateX(-50%)';
  118. style.flexDirection = 'column-reverse';
  119. break;
  120. case 'right':
  121. style.left = '100%';
  122. style.top = '50%';
  123. style.transform = 'translateY(-50%)';
  124. style.flexDirection = 'row';
  125. break;
  126. case 'bottom':
  127. style.top = '100%';
  128. style.left = '50%';
  129. style.transform = 'translateX(-50%)';
  130. style.flexDirection = 'column';
  131. break;
  132. case 'left':
  133. style.right = '100%';
  134. style.top = '50%';
  135. style.transform = 'translateY(-50%)';
  136. style.flexDirection = 'row-reverse';
  137. break;
  138. }
  139. return uni.$u.deepMerge(style, uni.$u.addStyle(this.customStyle))
  140. }
  141. },
  142. watch: {
  143. active: {
  144. handler(newVal) {
  145. this.isActive = newVal
  146. },
  147. immediate: true
  148. },
  149. direction: {
  150. handler(newVal) {
  151. this.fabDirection = newVal
  152. }
  153. },
  154. position() {
  155. this.initPosition()
  156. }
  157. },
  158. mounted() {
  159. this.$nextTick(() => {
  160. this.init()
  161. })
  162. },
  163. // #ifdef VUE3
  164. emits: ['update:active', 'click','change'],
  165. // #endif
  166. methods: {
  167. async init() {
  168. await this.getBounding()
  169. this.initPosition()
  170. this.inited = true
  171. },
  172. async getBounding() {
  173. const windowInfo = this.$u.window()
  174. // 获取触发器尺寸
  175. try {
  176. // #ifndef APP-NVUE
  177. const triggerInfo = await this.$uGetRect('.u-fab__trigger-wrapper')
  178. this.fabSize.width = triggerInfo.width || 56
  179. this.fabSize.height = triggerInfo.height || 56
  180. // #endif
  181. // #ifdef APP-NVUE
  182. this.fabSize.width = 56
  183. this.fabSize.height = 56
  184. // #endif
  185. } catch (error) {
  186. console.log('获取触发器尺寸失败:', error)
  187. }
  188. const { top = 16, left = 16, right = 16, bottom = 16 } = this.gap
  189. this.screen.width = windowInfo.windowWidth
  190. this.screen.height = windowInfo.windowHeight
  191. // #ifdef H5
  192. this.screen.height = windowInfo.windowTop + windowInfo.windowHeight
  193. this.bounding.minTop = windowInfo.windowTop + top
  194. // #endif
  195. // #ifndef H5
  196. this.bounding.minTop = top
  197. // #endif
  198. this.bounding.minLeft = left
  199. this.bounding.maxLeft = this.screen.width - this.fabSize.width - right
  200. this.bounding.maxTop = this.screen.height - this.fabSize.height - bottom
  201. },
  202. initPosition() {
  203. const pos = this.position
  204. const { minLeft, minTop, maxLeft, maxTop } = this.bounding
  205. const centerY = (maxTop + minTop) / 2
  206. const centerX = (maxLeft + minLeft) / 2
  207. switch (pos) {
  208. case 'left-top':
  209. this.top = minTop
  210. this.left = minLeft
  211. break
  212. case 'right-top':
  213. this.top = minTop
  214. this.left = maxLeft
  215. break
  216. case 'left-bottom':
  217. this.top = maxTop
  218. this.left = minLeft
  219. break
  220. case 'right-bottom':
  221. this.top = maxTop
  222. this.left = maxLeft
  223. break
  224. case 'left-center':
  225. this.top = centerY
  226. this.left = minLeft
  227. break
  228. case 'right-center':
  229. this.top = centerY
  230. this.left = maxLeft
  231. break
  232. case 'top-center':
  233. this.top = minTop
  234. this.left = centerX
  235. break
  236. case 'bottom-center':
  237. this.top = maxTop
  238. this.left = centerX
  239. break
  240. }
  241. },
  242. handleTouchStart(e) {
  243. if (this.draggable === 'none') return
  244. const touch = e.touches[0]
  245. this.touchOffset.x = touch.clientX - this.left
  246. this.touchOffset.y = touch.clientY - this.top
  247. this.attractTransition = false
  248. },
  249. handleTouchMove(e) {
  250. if (this.draggable === 'none') return
  251. const touch = e.touches[0]
  252. const { minLeft, minTop, maxLeft, maxTop } = this.bounding
  253. let x = touch.clientX - this.touchOffset.x
  254. let y = touch.clientY - this.touchOffset.y
  255. if (x < minLeft) x = minLeft
  256. else if (x > maxLeft) x = maxLeft
  257. if (y < minTop) y = minTop
  258. else if (y > maxTop) y = maxTop
  259. this.top = y
  260. this.left = x
  261. },
  262. handleTouchEnd() {
  263. const screenCenterX = this.screen.width / 2
  264. const fabCenterX = this.left + this.fabSize.width / 2
  265. this.attractTransition = true
  266. // 自动吸附模式
  267. // 检查指定方向是否有足够空间
  268. const hasEnoughSpace = this.checkDirectionSpace(this.direction)
  269. // 自动计算最佳位置
  270. if (this.draggable === 'auto') {
  271. if (fabCenterX < screenCenterX) {
  272. this.left = this.bounding.minLeft
  273. } else {
  274. this.left = this.bounding.maxLeft
  275. }
  276. }
  277. if (hasEnoughSpace) {
  278. // 使用指定的方向
  279. this.fabDirection = this.direction
  280. this.adjustPositionForDirection(this.direction)
  281. } else {
  282. // 自动计算最佳位置
  283. if (fabCenterX < screenCenterX) {
  284. this.fabDirection = 'right'
  285. } else {
  286. this.fabDirection = 'left'
  287. }
  288. }
  289. },
  290. // 检查指定方向是否有足够空间
  291. checkDirectionSpace(direction) {
  292. const { minLeft, minTop, maxLeft, maxTop } = this.bounding
  293. const fabCenterX = this.left + this.fabSize.width / 2
  294. const fabCenterY = this.top + this.fabSize.height / 2
  295. const minSpaceNeeded = 100 // 菜单需要的最小空间
  296. switch (direction) {
  297. case 'top':
  298. return fabCenterY - minTop >= minSpaceNeeded
  299. case 'bottom':
  300. return maxTop - fabCenterY >= minSpaceNeeded
  301. case 'left':
  302. return fabCenterX - minLeft >= minSpaceNeeded
  303. case 'right':
  304. return maxLeft - fabCenterX >= minSpaceNeeded
  305. default:
  306. return false
  307. }
  308. },
  309. // 根据方向调整位置
  310. adjustPositionForDirection(direction) {
  311. const screenCenterX = this.screen.width / 2
  312. const screenCenterY = this.screen.height / 2
  313. switch (direction) {
  314. case 'top':
  315. case 'bottom':
  316. // 垂直方向时,水平居中或靠近边缘
  317. if (Math.abs(this.left + this.fabSize.width / 2 - screenCenterX) < 50) {
  318. // 如果接近中心,保持中心位置
  319. this.left = screenCenterX - this.fabSize.width / 2
  320. }
  321. break
  322. case 'left':
  323. case 'right':
  324. // 水平方向时,垂直居中或靠近边缘
  325. if (Math.abs(this.top + this.fabSize.height / 2 - screenCenterY) < 50) {
  326. // 如果接近中心,保持中心位置
  327. this.top = screenCenterY - this.fabSize.height / 2
  328. }
  329. break
  330. }
  331. },
  332. // 根据当前位置调整弹出方向(用于自由拖动模式)
  333. adjustDirectionByPosition() {
  334. const screenCenterX = this.screen.width / 2
  335. const screenCenterY = this.screen.height / 2
  336. const fabCenterX = this.left + this.fabSize.width / 2
  337. const fabCenterY = this.top + this.fabSize.height / 2
  338. // 根据位置智能选择弹出方向
  339. const distanceToLeft = fabCenterX - this.bounding.minLeft
  340. const distanceToRight = this.bounding.maxLeft - fabCenterX
  341. const distanceToTop = fabCenterY - this.bounding.minTop
  342. const distanceToBottom = this.bounding.maxTop - fabCenterY
  343. // 找到空间最大的方向
  344. const maxDistance = Math.max(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom)
  345. if (maxDistance === distanceToTop) {
  346. this.fabDirection = 'top'
  347. } else if (maxDistance === distanceToBottom) {
  348. this.fabDirection = 'bottom'
  349. } else if (maxDistance === distanceToLeft) {
  350. this.fabDirection = 'left'
  351. } else {
  352. this.fabDirection = 'right'
  353. }
  354. },
  355. handleClick() {
  356. if (this.disabled) {
  357. return
  358. }
  359. if (!this.expandable) {
  360. this.$emit('click')
  361. return
  362. }
  363. this.isActive = !this.isActive
  364. this.$emit('change')
  365. }
  366. }
  367. }
  368. </script>
  369. <style lang="scss" scoped>
  370. @import '../../libs/css/components.scss';
  371. .u-fab {
  372. position: fixed;
  373. &__trigger-wrapper {
  374. position: relative;
  375. z-index: 2;
  376. }
  377. &__actions {
  378. position: absolute;
  379. z-index: 1;
  380. display: flex;
  381. align-items: center;
  382. &--top {
  383. bottom: 100%;
  384. left: 50%;
  385. transform: translateX(-50%);
  386. margin-bottom: 8px;
  387. flex-direction: column-reverse;
  388. }
  389. &--right {
  390. left: 100%;
  391. top: 50%;
  392. transform: translateY(-50%);
  393. margin-left: 8px;
  394. flex-direction: row;
  395. }
  396. &--bottom {
  397. top: 100%;
  398. left: 50%;
  399. transform: translateX(-50%);
  400. margin-top: 8px;
  401. flex-direction: column;
  402. }
  403. &--left {
  404. right: 100%;
  405. top: 50%;
  406. transform: translateY(-50%);
  407. margin-right: 8px;
  408. flex-direction: row-reverse;
  409. }
  410. }
  411. }
  412. </style>