CNCEC_APP/uni_modules/uview-pro/components/u-fab/u-fab.vue

329 lines
10 KiB
Vue
Raw Permalink Normal View History

2026-03-25 14:54:15 +08:00
<template>
<view
class="u-fab"
:style="$u.toStyle(fabStyle, customStyle)"
:class="[customClass]"
@touchstart="handleTouchstart"
@touchmove.stop.prevent="handleTouchmove"
@touchend="handleTouchend"
>
<view class="u-fab-trigger" id="trigger">
<slot name="trigger">
<u-button
custom-class="u-fab-trigger-btn"
custom-style="width:112rpx;height:112rpx;border-radius:112rpx;"
:type="type"
:disabled="disabled"
:throttle-time="0"
@click="handleBtnClick"
>
<u-icon :name="expansion ? 'close' : 'plus'" size="36rpx"></u-icon>
</u-button>
</slot>
</view>
<view class="u-fab-actions" id="actions" :class="{ 'u-fab-actions__show': expansion }" :style="actionsStyle">
<slot></slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-fab',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { directionConfig, FabProps } from './types';
import { computed, getCurrentInstance, onMounted, reactive, ref, useSlots, watch } from 'vue';
import { $u } from '../../';
const props = defineProps(FabProps);
const emit = defineEmits(['trigger']);
const slots = useSlots();
const instance = getCurrentInstance();
const sysInfo = $u.sys();
const dragging = ref(true);
const minLeft = ref(0);
const maxLeft = ref(0);
const minTop = ref(0);
const maxTop = ref(0);
const expansion = ref(false);
const direction = ref(props.direction);
const effectiveWindowHeight = ref(sysInfo.windowHeight);
// #ifdef H5
effectiveWindowHeight.value = sysInfo.windowTop + sysInfo.windowHeight;
// #endif
const position = reactive({
top: 0,
left: 0
});
const actions = ref();
const btnInfo = ref({
width: 56,
height: 56
});
const start = reactive({
x: 0,
y: 0
});
// 计算悬浮按钮样式
const fabStyle = computed(() => {
const style: Record<string, any> = {
transition: dragging.value ? 'none' : 'all 0.3s ease',
zIndex: props.zIndex,
left: `${position.left}px`,
top: `${position.top}px`
};
return style;
});
// 动画样式
const actionsStyle = computed(() => {
const config = directionConfig[direction.value];
const base: Record<string, any> = {
[config.opposite]: '100%',
[config.sizeKey]: '100%',
[config.positionKey]: 0,
flexDirection: config.flexBase,
transition: 'all 0.28s cubic-bezier(0.3, 0, 0.2, 1)'
};
// 展开/收缩动画:根据方向设置初始偏移
const offset = 12; // rpx/px 相对轻微偏移,视觉更顺滑
if (expansion.value) {
base.transform = 'translate3d(0,0,0) scale(1)';
base.opacity = 1;
} else {
// 隐藏时做一个方向上的微位移并缩放以优化动画感
if (direction.value === 'top') base.transform = `translate3d(0, ${offset}px, 0) scale(0.96)`;
else if (direction.value === 'bottom') base.transform = `translate3d(0, -${offset}px, 0) scale(0.96)`;
else if (direction.value === 'left') base.transform = `translate3d(${offset}px, 0, 0) scale(0.96)`;
else base.transform = `translate3d(-${offset}px, 0, 0) scale(0.96)`;
base.opacity = 0;
}
return base;
});
watch(
() => [props.position, props.gap],
() => {
initPosition();
}
);
watch(
() => props.direction,
() => {
if (expansion.value) direction.value = calcDirection();
}
);
// helper支持 gap 为 number 或对象形式
function getGap(side: 'top' | 'left' | 'right' | 'bottom') {
const g = props.gap as any;
if (typeof g === 'number') return g;
if (g && typeof g === 'object' && g[side] !== undefined) return Number(g[side]);
return 0;
}
// 拖动开始事件
function handleTouchstart(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
start.x = touches.clientX;
start.y = touches.clientY;
dragging.value = true;
}
// 拖动移动事件
function handleTouchmove(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
const deltaX = touches.clientX - start.x;
const deltaY = touches.clientY - start.y;
position.left += deltaX;
position.top += deltaY;
start.x = touches.clientX;
start.y = touches.clientY;
// 设置边界,防止拖出边界
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
}
// 拖动结束事件
function handleTouchend() {
if (props.disabled || !props.draggable) return;
dragging.value = false;
// 如果 autoStick 为 false则释放后不进行自动吸边仅做边界限制
if (props.autoStick === false) {
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
return;
}
const middle = sysInfo.windowWidth / 2;
const buttonCenter = position.left + btnInfo.value.width / 2;
// 自动吸边,按钮中心位置大于视口的一半时,自动依附在右边,不然就是左边
position.left =
buttonCenter > middle ? sysInfo.windowWidth - btnInfo.value.width - getGap('right') : getGap('left');
if (expansion.value) direction.value = calcDirection();
}
// 按钮点击事件
function handleBtnClick() {
if (slots?.default) {
expansion.value = !expansion.value;
if (expansion.value) direction.value = calcDirection();
} else {
emit('trigger');
}
}
// 计算方向
function calcDirection() {
if (!actions.value) return props.direction;
let dir = props.direction;
const actionsHeight = actions.value?.height || 0;
const actionsWidth = actions.value?.width || 0;
// 菜单展开时,如果位置不够,则反转方向
if (dir === 'top') {
// 按钮上方剩余空间: 按钮顶部 - 顶部边距
if (position.top - minTop.value < actionsHeight) dir = 'bottom';
} else if (dir === 'bottom') {
// 按钮下方剩余空间: 有效窗口高度 - (按钮顶部 + 按钮高 + 边距)
const bottom = effectiveWindowHeight.value - (position.top + btnInfo.value.height + getGap('bottom'));
if (bottom < actionsHeight) dir = 'top';
} else if (dir === 'left') {
// 按钮左侧剩余空间: 有效窗口宽度 - 边距
if (position.left - getGap('left') < actionsWidth) dir = 'right';
} else if (dir === 'right') {
// 按钮右侧剩余空间: 有效窗口宽度 - (按钮左侧 + 按钮宽 + 边距)
const right = sysInfo.windowWidth - (position.left + btnInfo.value.width + getGap('right'));
if (right < actionsWidth) dir = 'left';
}
return dir;
}
// 初始化位置
function initPosition() {
// 根据 props.position 计算初始 left/top
const pos = props.position || 'right-bottom';
const winW = sysInfo.windowWidth;
const winH = effectiveWindowHeight.value;
switch (pos) {
case 'left-top':
position.left = getGap('left');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'right-top':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'left-bottom':
position.left = getGap('left');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'right-bottom':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'left-center':
position.left = getGap('left');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'right-center':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'top-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'bottom-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
default:
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
}
}
onMounted(async () => {
btnInfo.value = await $u.getRect('#trigger', instance);
actions.value = await $u.getRect('#actions', instance);
initPosition();
minLeft.value = getGap('left');
minTop.value = getGap('top') + sysInfo.windowTop;
maxLeft.value = sysInfo.windowWidth - btnInfo.value.width - getGap('right');
maxTop.value = effectiveWindowHeight.value - btnInfo.value.height - getGap('bottom');
});
defineExpose({
toggle: handleBtnClick
});
</script>
<style scoped lang="scss">
.u-fab {
position: fixed;
.u-fab-trigger {
:deep(.u-fab-trigger-btn) {
width: 112rpx;
height: 112rpx;
border-radius: 112rpx;
&::after {
border-radius: 112rpx;
}
}
}
.u-fab-actions {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
opacity: 0;
transform-origin: center;
will-change: transform, opacity;
transition: all 0.28s cubic-bezier(0.3, 0, 0.2, 1);
&.u-fab-actions__show {
visibility: visible;
opacity: 1;
}
}
}
</style>