329 lines
10 KiB
Vue
329 lines
10 KiB
Vue
<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>
|