This commit is contained in:
2025-08-12 15:04:01 +08:00
parent df3f5f5269
commit 6708fb8eeb
544 changed files with 75169 additions and 17 deletions
@@ -0,0 +1,64 @@
export default {
props: {
// 是否激活
active: {
type: Boolean,
default: ()=> uni.$u.props.fab.active
},
// 类型
type: {
type: String,
default: ()=> uni.$u.props.fab.type
},
// 悬浮按钮位置
position: {
type: String,
default: ()=> uni.$u.props.fab.position
},
// 按钮拖动模式:'auto'(自动吸附)、'free'(自由拖动)、'none'(不可拖动)
draggable: {
type: String,
default: ()=> uni.$u.props.fab.draggable
},
// 悬浮按钮菜单弹出方向
direction: {
type: String,
default: ()=> uni.$u.props.fab.direction
},
// 是否禁用
disabled: {
type: Boolean,
default: ()=> uni.$u.props.fab.disabled
},
// 悬浮按钮未展开时的图标
inactiveIcon: {
type: String,
default: ()=> uni.$u.props.fab.inactiveIcon
},
// 悬浮按钮展开时的图标
activeIcon: {
type: String,
default: ()=> uni.$u.props.fab.activeIcon
},
// 悬浮按钮大小
size: {
type: [String, Number],
default: ()=> uni.$u.props.fab.size
},
// 自定义悬浮按钮层级
zIndex: {
type: [String, Number],
default: ()=> uni.$u.props.fab.zIndex
},
// 自定义悬浮按钮与可视区域边缘的间距
gap: {
type: Object,
default: ()=> uni.$u.props.fab.gap
},
// 用于控制点击时是否展开菜单
expandable: {
type: Boolean,
default: ()=> uni.$u.props.fab.expandable
}
}
}
@@ -0,0 +1,458 @@
<template>
<view
@touchmove.stop.prevent="handleTouchMove"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
class="u-fab"
:class="[`u-fab--${this.position}`]"
:style="[rootStyle]"
@click.stop=""
>
<view :style="{ visibility: inited ? 'visible' : 'hidden' }" class="u-fab__trigger-wrapper">
<slot name="trigger" v-if="$slots.trigger || $slots.$trigger"></slot>
<u-button
v-else
shape="circle"
@click="handleClick"
:type="type"
:disabled="disabled"
:custom-style="triggerStyle"
:icon="isActive ? activeIcon : inactiveIcon"
></u-button>
</view>
<u-transition
v-if="expandable"
:show="isActive"
mode="fade"
:duration="300"
:custom-style="actionsStyle"
>
<view class="u-fab__actions" :class="[`u-fab__actions--${this.fabDirection}`]">
<slot></slot>
</view>
</u-transition>
</view>
</template>
<script>
// #ifdef APP-NVUE
const dom = uni.requireNativePlugin('dom')
// #endif
import props from './props.js';
import mixin from '../../libs/mixin/mixin'
import mpMixin from '../../libs/mixin/mpMixin';
/**
* fab 悬浮按钮
* @description 悬浮动作按钮组件,按下可显示一组动作按钮
* @tutorial https://uview.d3u.cn/components/fab.html
* @property {Boolean} active 是否激活 (默认 false )
* @property {String} type 类型,可选值为 primary、success、info、warning、error、default (默认 'primary' )
* @property {String} position 悬浮按钮位置,可选值为 left-top、right-top、left-bottom、right-bottom、left-center、right-center、top-center、bottom-center (默认 'right-bottom' )
* @property {String} draggable 按钮拖动模式,可选值为 auto(自动吸附)、free(自由拖动)、none(不可拖动) (默认 'auto' )
* @property {String} direction 悬浮按钮菜单弹出方向,可选值为 top、right、bottom、left (默认 'top' )
* @property {Boolean} disabled 是否禁用 (默认 false )
* @property {String} inactiveIcon 悬浮按钮未展开时的图标 (默认 'plus' )
* @property {String} activeIcon 悬浮按钮展开时的图标 (默认 'close' )
* @property {String} iconColor 悬浮按钮图标颜色 (默认 '#fff' )
* @property {Number} zIndex 自定义悬浮按钮层级 (默认 99 )
* @property {Object} gap 自定义悬浮按钮与可视区域边缘的间距 (默认 {top: 16, left: 16, right: 16, bottom: 16} )
* @property {Boolean} expandable 用于控制点击时是否展开菜单 (默认 true )
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click expandable 设置为 false 时,点击悬浮按钮触发
* @event {Function} change 菜单状态改变时触发
* @event {Function} update:active 激活状态改变时触发
* @example <u-fab v-model:active="active" :type="type" :position="position" :direction="direction"></u-fab>
*/
export default {
name: "u-fab",
mixins: [mpMixin, mixin, props],
data() {
return {
inited: false, // 是否初始化完成
isActive: false, // 是否激活状态
fabDirection: this.direction, // 实际弹出方向
top: 0,
left: 0,
screen: { width: 0, height: 0 },
fabSize: { width: 56, height: 56 },
bounding: {
minTop: 0,
minLeft: 0,
maxTop: 0,
maxLeft: 0
},
touchOffset: { x: 0, y: 0 }, // 按下时坐标相对于元素的偏移量
attractTransition: false // 是否显示吸附动画
}
},
computed: {
rootStyle() {
const style = {
position: 'fixed',
top: this.$u.addUnit(this.top),
left: this.$u.addUnit(this.left),
zIndex: this.zIndex,
transition: this.attractTransition ? 'all ease 0.3s' : 'none'
}
return uni.$u.deepMerge(style, uni.$u.addStyle(this.customStyle))
},
triggerStyle() {
return {
width: this.$u.addUnit(this.size),
height: this.$u.addUnit(this.size),
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)'
}
},
actionsStyle() {
const style = {
position: 'absolute',
zIndex: 1,
display: 'flex',
alignItems: 'center'
}
switch (this.fabDirection) {
case 'top':
style.bottom = '100%';
style.left = '50%';
style.transform = 'translateX(-50%)';
style.flexDirection = 'column-reverse';
break;
case 'right':
style.left = '100%';
style.top = '50%';
style.transform = 'translateY(-50%)';
style.flexDirection = 'row';
break;
case 'bottom':
style.top = '100%';
style.left = '50%';
style.transform = 'translateX(-50%)';
style.flexDirection = 'column';
break;
case 'left':
style.right = '100%';
style.top = '50%';
style.transform = 'translateY(-50%)';
style.flexDirection = 'row-reverse';
break;
}
return uni.$u.deepMerge(style, uni.$u.addStyle(this.customStyle))
}
},
watch: {
active: {
handler(newVal) {
this.isActive = newVal
},
immediate: true
},
direction: {
handler(newVal) {
this.fabDirection = newVal
}
},
position() {
this.initPosition()
}
},
mounted() {
this.$nextTick(() => {
this.init()
})
},
// #ifdef VUE3
emits: ['update:active', 'click','change'],
// #endif
methods: {
async init() {
await this.getBounding()
this.initPosition()
this.inited = true
},
async getBounding() {
const windowInfo = this.$u.window()
// 获取触发器尺寸
try {
// #ifndef APP-NVUE
const triggerInfo = await this.$uGetRect('.u-fab__trigger-wrapper')
this.fabSize.width = triggerInfo.width || 56
this.fabSize.height = triggerInfo.height || 56
// #endif
// #ifdef APP-NVUE
this.fabSize.width = 56
this.fabSize.height = 56
// #endif
} catch (error) {
console.log('获取触发器尺寸失败:', error)
}
const { top = 16, left = 16, right = 16, bottom = 16 } = this.gap
this.screen.width = windowInfo.windowWidth
this.screen.height = windowInfo.windowHeight
// #ifdef H5
this.screen.height = windowInfo.windowTop + windowInfo.windowHeight
this.bounding.minTop = windowInfo.windowTop + top
// #endif
// #ifndef H5
this.bounding.minTop = top
// #endif
this.bounding.minLeft = left
this.bounding.maxLeft = this.screen.width - this.fabSize.width - right
this.bounding.maxTop = this.screen.height - this.fabSize.height - bottom
},
initPosition() {
const pos = this.position
const { minLeft, minTop, maxLeft, maxTop } = this.bounding
const centerY = (maxTop + minTop) / 2
const centerX = (maxLeft + minLeft) / 2
switch (pos) {
case 'left-top':
this.top = minTop
this.left = minLeft
break
case 'right-top':
this.top = minTop
this.left = maxLeft
break
case 'left-bottom':
this.top = maxTop
this.left = minLeft
break
case 'right-bottom':
this.top = maxTop
this.left = maxLeft
break
case 'left-center':
this.top = centerY
this.left = minLeft
break
case 'right-center':
this.top = centerY
this.left = maxLeft
break
case 'top-center':
this.top = minTop
this.left = centerX
break
case 'bottom-center':
this.top = maxTop
this.left = centerX
break
}
},
handleTouchStart(e) {
if (this.draggable === 'none') return
const touch = e.touches[0]
this.touchOffset.x = touch.clientX - this.left
this.touchOffset.y = touch.clientY - this.top
this.attractTransition = false
},
handleTouchMove(e) {
if (this.draggable === 'none') return
const touch = e.touches[0]
const { minLeft, minTop, maxLeft, maxTop } = this.bounding
let x = touch.clientX - this.touchOffset.x
let y = touch.clientY - this.touchOffset.y
if (x < minLeft) x = minLeft
else if (x > maxLeft) x = maxLeft
if (y < minTop) y = minTop
else if (y > maxTop) y = maxTop
this.top = y
this.left = x
},
handleTouchEnd() {
const screenCenterX = this.screen.width / 2
const fabCenterX = this.left + this.fabSize.width / 2
this.attractTransition = true
// 自动吸附模式
// 检查指定方向是否有足够空间
const hasEnoughSpace = this.checkDirectionSpace(this.direction)
// 自动计算最佳位置
if (this.draggable === 'auto') {
if (fabCenterX < screenCenterX) {
this.left = this.bounding.minLeft
} else {
this.left = this.bounding.maxLeft
}
}
if (hasEnoughSpace) {
// 使用指定的方向
this.fabDirection = this.direction
this.adjustPositionForDirection(this.direction)
} else {
// 自动计算最佳位置
if (fabCenterX < screenCenterX) {
this.fabDirection = 'right'
} else {
this.fabDirection = 'left'
}
}
},
// 检查指定方向是否有足够空间
checkDirectionSpace(direction) {
const { minLeft, minTop, maxLeft, maxTop } = this.bounding
const fabCenterX = this.left + this.fabSize.width / 2
const fabCenterY = this.top + this.fabSize.height / 2
const minSpaceNeeded = 100 // 菜单需要的最小空间
switch (direction) {
case 'top':
return fabCenterY - minTop >= minSpaceNeeded
case 'bottom':
return maxTop - fabCenterY >= minSpaceNeeded
case 'left':
return fabCenterX - minLeft >= minSpaceNeeded
case 'right':
return maxLeft - fabCenterX >= minSpaceNeeded
default:
return false
}
},
// 根据方向调整位置
adjustPositionForDirection(direction) {
const screenCenterX = this.screen.width / 2
const screenCenterY = this.screen.height / 2
switch (direction) {
case 'top':
case 'bottom':
// 垂直方向时,水平居中或靠近边缘
if (Math.abs(this.left + this.fabSize.width / 2 - screenCenterX) < 50) {
// 如果接近中心,保持中心位置
this.left = screenCenterX - this.fabSize.width / 2
}
break
case 'left':
case 'right':
// 水平方向时,垂直居中或靠近边缘
if (Math.abs(this.top + this.fabSize.height / 2 - screenCenterY) < 50) {
// 如果接近中心,保持中心位置
this.top = screenCenterY - this.fabSize.height / 2
}
break
}
},
// 根据当前位置调整弹出方向(用于自由拖动模式)
adjustDirectionByPosition() {
const screenCenterX = this.screen.width / 2
const screenCenterY = this.screen.height / 2
const fabCenterX = this.left + this.fabSize.width / 2
const fabCenterY = this.top + this.fabSize.height / 2
// 根据位置智能选择弹出方向
const distanceToLeft = fabCenterX - this.bounding.minLeft
const distanceToRight = this.bounding.maxLeft - fabCenterX
const distanceToTop = fabCenterY - this.bounding.minTop
const distanceToBottom = this.bounding.maxTop - fabCenterY
// 找到空间最大的方向
const maxDistance = Math.max(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom)
if (maxDistance === distanceToTop) {
this.fabDirection = 'top'
} else if (maxDistance === distanceToBottom) {
this.fabDirection = 'bottom'
} else if (maxDistance === distanceToLeft) {
this.fabDirection = 'left'
} else {
this.fabDirection = 'right'
}
},
handleClick() {
if (this.disabled) {
return
}
if (!this.expandable) {
this.$emit('click')
return
}
this.isActive = !this.isActive
this.$emit('change')
}
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/components.scss';
.u-fab {
position: fixed;
&__trigger-wrapper {
position: relative;
z-index: 2;
}
&__actions {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
&--top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
flex-direction: column-reverse;
}
&--right {
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
flex-direction: row;
}
&--bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
flex-direction: column;
}
&--left {
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 8px;
flex-direction: row-reverse;
}
}
}
</style>