看板
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import type { CSSProperties, ExtractPropTypes, PropType } from 'vue';
|
||||
import type { FabDirection, FabGap, FabPosition, ThemeType } from '../../types/global';
|
||||
import { baseProps } from '../common/props';
|
||||
|
||||
/**
|
||||
* fab 悬浮按钮类型定义
|
||||
* @description 供 u-fab 组件 props 使用
|
||||
*/
|
||||
|
||||
export const FabProps = {
|
||||
...baseProps,
|
||||
/** 按钮的预置样式,primary,info,error,warning,success */
|
||||
type: { type: String as PropType<ThemeType>, default: 'primary' },
|
||||
/** 是否禁止状态 */
|
||||
disabled: { type: Boolean, default: false },
|
||||
/** 按钮能否可以拖动 */
|
||||
draggable: { type: Boolean, default: false },
|
||||
/** 按钮与边缘的间距,单位 px。支持 number 或对象 {top,left,right,bottom},优先级按键存在顺序 */
|
||||
gap: {
|
||||
type: Object as PropType<FabGap>,
|
||||
default: () => ({
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16
|
||||
})
|
||||
},
|
||||
/** 拖动结束时是否自动吸边(仅当 draggable 为 true 时生效) */
|
||||
autoStick: { type: Boolean, default: true },
|
||||
/** 预设定位:控制组件默认停靠位置 */
|
||||
position: {
|
||||
type: String as PropType<FabPosition>,
|
||||
default: 'right-bottom'
|
||||
},
|
||||
/** 菜单出现的方向 */
|
||||
direction: { type: String as PropType<FabDirection>, default: 'top' },
|
||||
/** 按钮自定义层级 */
|
||||
zIndex: { type: Number, default: 99 }
|
||||
};
|
||||
|
||||
export type FabProps = ExtractPropTypes<typeof FabProps>;
|
||||
|
||||
interface DirectionConfig {
|
||||
opposite: keyof CSSProperties;
|
||||
sizeKey: 'width' | 'height';
|
||||
positionKey: 'left' | 'top';
|
||||
flexBase: 'row' | 'row-reverse' | 'column' | 'column-reverse';
|
||||
}
|
||||
|
||||
export const directionConfig: Record<FabDirection, DirectionConfig> = {
|
||||
top: {
|
||||
opposite: 'bottom',
|
||||
sizeKey: 'width',
|
||||
positionKey: 'left',
|
||||
flexBase: 'column'
|
||||
},
|
||||
bottom: {
|
||||
opposite: 'top',
|
||||
sizeKey: 'width',
|
||||
positionKey: 'left',
|
||||
flexBase: 'column-reverse'
|
||||
},
|
||||
left: {
|
||||
opposite: 'right',
|
||||
sizeKey: 'height',
|
||||
positionKey: 'top',
|
||||
flexBase: 'row'
|
||||
},
|
||||
right: {
|
||||
opposite: 'left',
|
||||
sizeKey: 'height',
|
||||
positionKey: 'top',
|
||||
flexBase: 'row-reverse'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user