447 lines
16 KiB
Vue
447 lines
16 KiB
Vue
|
|
<template>
|
|||
|
|
<view
|
|||
|
|
v-if="props.show"
|
|||
|
|
class="u-tabbar"
|
|||
|
|
:class="customClass"
|
|||
|
|
:style="$u.toStyle(customStyle)"
|
|||
|
|
@touchmove.stop.prevent="() => {}"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="u-tabbar__content safe-area-inset-bottom"
|
|||
|
|
:style="{ height: $u.addUnit(props.height), backgroundColor: props.bgColor, zIndex: uZIndex }"
|
|||
|
|
:class="{ 'u-border-top': props.borderTop }"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="u-tabbar__content__item"
|
|||
|
|
v-for="(item, index) in props.list"
|
|||
|
|
:key="index"
|
|||
|
|
:class="{ 'u-tabbar__content__circle': props.midButton && item.midButton }"
|
|||
|
|
@tap.stop="clickHandler(index)"
|
|||
|
|
:style="{ backgroundColor: props.bgColor }"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="u-tabbar__content__item__container"
|
|||
|
|
:class="{ 'u-tabbar__content__circle__container': props.midButton && item.midButton }"
|
|||
|
|
:style="containerStyle(index)"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
v-if="item.iconPath || item.selectedIconPath"
|
|||
|
|
:class="[
|
|||
|
|
props.midButton && item.midButton
|
|||
|
|
? 'u-tabbar__content__circle__icon'
|
|||
|
|
: 'u-tabbar__content__item__icon'
|
|||
|
|
]"
|
|||
|
|
>
|
|||
|
|
<u-icon
|
|||
|
|
:size="getIconSize(index)"
|
|||
|
|
:name="elIconPath(index)"
|
|||
|
|
img-mode="scaleToFill"
|
|||
|
|
:color="elColor(index)"
|
|||
|
|
:custom-prefix="getCustomPrefix(index)"
|
|||
|
|
></u-icon>
|
|||
|
|
<u-badge
|
|||
|
|
:count="item.count"
|
|||
|
|
:is-dot="item.isDot"
|
|||
|
|
v-if="item.count || item.isDot"
|
|||
|
|
:offset="[
|
|||
|
|
getBadgeOffsetTop(item.count || 0, item.isDot || false),
|
|||
|
|
getOffsetRight(item.count || 0, item.isDot || false)
|
|||
|
|
]"
|
|||
|
|
></u-badge>
|
|||
|
|
</view>
|
|||
|
|
<!-- #ifdef APP-PLUS -->
|
|||
|
|
<u-gap :height="gap"></u-gap>
|
|||
|
|
<!-- #endif -->
|
|||
|
|
<view
|
|||
|
|
v-if="item.text"
|
|||
|
|
class="u-tabbar__content__item__text"
|
|||
|
|
:class="{
|
|||
|
|
'u-tabbar__content__item__text--center':
|
|||
|
|
item.text && !(item.iconPath || item.selectedIconPath)
|
|||
|
|
}"
|
|||
|
|
>
|
|||
|
|
<text
|
|||
|
|
class="u-line-1"
|
|||
|
|
:style="{ color: elColor(index), fontSize: $u.addUnit(getTextSize(index)) }"
|
|||
|
|
>
|
|||
|
|
{{ item.text }}
|
|||
|
|
</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<view
|
|||
|
|
v-if="props.midButton"
|
|||
|
|
class="u-tabbar__content__circle__border"
|
|||
|
|
:class="{ 'u-border': props.borderTop }"
|
|||
|
|
:style="{ backgroundColor: props.bgColor, left: midButtonLeft }"
|
|||
|
|
></view>
|
|||
|
|
</view>
|
|||
|
|
<!-- 这里加上一个48rpx的高度,是为了增高有凸起按钮时的防塌陷高度(也即按钮凸出来部分的高度) -->
|
|||
|
|
<!-- calc 计算0时单位不一致会计算失败,这里+1px -->
|
|||
|
|
<view
|
|||
|
|
class="u-fixed-placeholder safe-area-inset-bottom"
|
|||
|
|
:style="{ height: `calc(${$u.addUnit(props.height)} + ${props.midButton ? '60rpx' : '1px'})` }"
|
|||
|
|
></view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script lang="ts">
|
|||
|
|
export default {
|
|||
|
|
name: 'u-tabbar',
|
|||
|
|
options: {
|
|||
|
|
addGlobalClass: true,
|
|||
|
|
// #ifndef MP-TOUTIAO
|
|||
|
|
virtualHost: true,
|
|||
|
|
// #endif
|
|||
|
|
styleIsolation: 'shared'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted } from 'vue';
|
|||
|
|
import { $u } from '../..';
|
|||
|
|
import { TabbarProps } from './types';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* u-tabbar 底部导航栏
|
|||
|
|
* @property {Boolean} show 显示与否
|
|||
|
|
* @property {String|Number} value 通过v-model绑定current值
|
|||
|
|
* @property {String} bgColor 整个tabbar的背景颜色
|
|||
|
|
* @property {String|Number} height tabbar的高度,默认50px,单位任意,如果为数值,则为rpx单位
|
|||
|
|
* @property {String|Number} iconSize 非凸起图标的大小,单位任意,数值默认rpx
|
|||
|
|
* @property {String|Number} midButtonSize 凸起的图标的大小,单位任意,数值默认rpx
|
|||
|
|
* @property {String} activeColor 激活时的演示,包括字体图标,提示文字等的演示
|
|||
|
|
* @property {String} inactiveColor 未激活时的颜色
|
|||
|
|
* @property {Boolean} midButton 是否显示中部的凸起按钮
|
|||
|
|
* @property {Array} list 配置参数
|
|||
|
|
* @property {Function} beforeSwitch 切换前的回调
|
|||
|
|
* @property {Boolean} borderTop 是否显示顶部的横线
|
|||
|
|
* @property {Boolean} hideTabBar 是否隐藏原生tabbar
|
|||
|
|
* @property {String|Number} gap icon和text的间距,单位任意,数值默认rpx
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const props = defineProps(TabbarProps);
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{ (e: 'change', index: number): void; (e: 'update:modelValue', index: number): void }>();
|
|||
|
|
|
|||
|
|
// 计算z-index值
|
|||
|
|
const uZIndex = computed(() => props?.zIndex ?? $u.zIndex.tabbar);
|
|||
|
|
|
|||
|
|
// 由于安卓太菜了,通过css居中凸起按钮的外层元素有误差,故通过js计算将其居中
|
|||
|
|
const midButtonLeft = ref('50%');
|
|||
|
|
const pageUrl = ref(''); // 当前页面URL
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 是否隐藏原生tabbar
|
|||
|
|
// 注意:如果当前页面不是tabbar页面,浏览器控制台会报错:{errMsg: 'hideTabBar:fail not TabBar page'}
|
|||
|
|
// if (props.hideTabBar) uni.hideTabBar();
|
|||
|
|
// 获取引入了u-tabbar页面的路由地址,该地址没有路径前面的"/"
|
|||
|
|
const pages = getCurrentPages();
|
|||
|
|
// 页面栈中的最后一个即为项为当前页面,route属性为页面路径
|
|||
|
|
pageUrl.value = pages[pages.length - 1].route as string;
|
|||
|
|
if (props.midButton) getMidButtonLeft();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算当前item的icon路径
|
|||
|
|
*/
|
|||
|
|
const elIconPath = computed<(index: number) => string | undefined>(() => {
|
|||
|
|
return (index: number) => {
|
|||
|
|
// 历遍u-tabbar的每一项item时,判断是否传入了pagePath参数,如果传入了
|
|||
|
|
// 和data中的pageUrl参数对比,如果相等,即可判断当前的item对应当前的tabbar页面,设置高亮图标
|
|||
|
|
// 采用这个方法,可以无需使用v-model绑定的value值
|
|||
|
|
const pagePath = props.list[index]?.pagePath;
|
|||
|
|
// 如果定义了pagePath属性,意味着使用系统自带tabbar方案,否则使用一个页面用几个组件模拟tabbar页面的方案
|
|||
|
|
// 这两个方案对处理tabbar item的激活与否方式不一样
|
|||
|
|
if (pagePath) {
|
|||
|
|
if (pagePath === pageUrl.value || pagePath === '/' + pageUrl.value) {
|
|||
|
|
return props.list[index].selectedIconPath;
|
|||
|
|
} else {
|
|||
|
|
return props.list[index].iconPath;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 普通方案中,索引等于v-model值时,即为激活项
|
|||
|
|
return index == props.modelValue ? props.list[index].selectedIconPath : props.list[index].iconPath;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算当前item的颜色
|
|||
|
|
*/
|
|||
|
|
const elColor = computed<(index: number) => string>(() => {
|
|||
|
|
return (index: number) => {
|
|||
|
|
// 判断方法同理于elIconPath
|
|||
|
|
const pagePath = props.list[index]?.pagePath;
|
|||
|
|
if (pagePath) {
|
|||
|
|
if (pagePath === pageUrl.value || pagePath === '/' + pageUrl.value) return props.activeColor;
|
|||
|
|
else return props.inactiveColor;
|
|||
|
|
} else {
|
|||
|
|
return index == props.modelValue ? props.activeColor : props.inactiveColor;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算当前item的custom-prefix
|
|||
|
|
* customIcon为boolean时:true为"custom-icon",false为"uicon"
|
|||
|
|
* customIcon为string时:直接使用该值
|
|||
|
|
* customIcon为空时:默认"uicon"
|
|||
|
|
*/
|
|||
|
|
function getCustomPrefix(index: number): string {
|
|||
|
|
const customIcon = props.list[index]?.customIcon;
|
|||
|
|
|
|||
|
|
// 如果为空(undefined/null),返回默认值
|
|||
|
|
if (customIcon === undefined || customIcon === null || customIcon === '') {
|
|||
|
|
return 'uicon';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果是字符串类型,直接返回
|
|||
|
|
if (typeof customIcon === 'string') {
|
|||
|
|
return customIcon;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果是boolean类型
|
|||
|
|
if (typeof customIcon === 'boolean') {
|
|||
|
|
return customIcon ? 'custom-icon' : 'uicon';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 默认返回uicon
|
|||
|
|
return 'uicon';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 点击tabbar item
|
|||
|
|
*/
|
|||
|
|
async function clickHandler(index: number) {
|
|||
|
|
if (props.beforeSwitch && typeof props.beforeSwitch === 'function') {
|
|||
|
|
// 执行回调,同时传入索引当作参数
|
|||
|
|
let beforeSwitchResult = props.beforeSwitch(index);
|
|||
|
|
// 判断是否返回了promise
|
|||
|
|
if (
|
|||
|
|
typeof beforeSwitchResult === 'object' &&
|
|||
|
|
beforeSwitchResult !== null &&
|
|||
|
|
typeof beforeSwitchResult.then === 'function'
|
|||
|
|
) {
|
|||
|
|
await beforeSwitchResult
|
|||
|
|
.then(() => {
|
|||
|
|
// promise返回成功,
|
|||
|
|
switchTab(index);
|
|||
|
|
})
|
|||
|
|
.catch(() => {});
|
|||
|
|
} else if (beforeSwitchResult === true) {
|
|||
|
|
// 如果返回true
|
|||
|
|
switchTab(index);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
switchTab(index);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 切换tab
|
|||
|
|
*/
|
|||
|
|
function switchTab(index: number) {
|
|||
|
|
// 发出事件和修改v-model绑定的值
|
|||
|
|
emit('change', index);
|
|||
|
|
// 如果有配置pagePath属性,使用uni.switchTab进行跳转
|
|||
|
|
if (props.list[index]?.pagePath) {
|
|||
|
|
uni.switchTab({ url: props.list[index].pagePath as string });
|
|||
|
|
} else {
|
|||
|
|
// 如果配置了papgePath属性,将不会双向绑定v-model传入的value值
|
|||
|
|
// 因为这个模式下,不再需要v-model绑定的value值了,而是通过getCurrentPages()适配
|
|||
|
|
emit('update:modelValue', index);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算角标的right值
|
|||
|
|
*/
|
|||
|
|
function getOffsetRight(count: number, isDot: boolean): number {
|
|||
|
|
// 点类型,count大于9(两位数),分别设置不同的right值,避免位置太挤
|
|||
|
|
if (isDot) {
|
|||
|
|
return -20;
|
|||
|
|
} else if (count > 9) {
|
|||
|
|
return -40;
|
|||
|
|
} else {
|
|||
|
|
return -30;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算角标的top值,在垂直布局下调整位置
|
|||
|
|
*/
|
|||
|
|
function getBadgeOffsetTop(count: number, isDot: boolean): number {
|
|||
|
|
// 在垂直布局下,角标相对于icon的top偏移需要调整
|
|||
|
|
// 由于icon现在在flex容器中,需要更小的top偏移
|
|||
|
|
return -2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取单项icon尺寸(单项优先级高于props)
|
|||
|
|
*/
|
|||
|
|
function getIconSize(index: number) {
|
|||
|
|
const item = props.list[index] || {};
|
|||
|
|
if (props.midButton && item.midButton) {
|
|||
|
|
return props.midButtonSize;
|
|||
|
|
}
|
|||
|
|
if (item.iconSize !== undefined && item.iconSize !== null && item.iconSize !== '') {
|
|||
|
|
return item.iconSize;
|
|||
|
|
}
|
|||
|
|
return props.iconSize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取单项text尺寸(单项优先级高于props)
|
|||
|
|
*/
|
|||
|
|
function getTextSize(index: number) {
|
|||
|
|
const item = props.list[index] || {};
|
|||
|
|
if (item.textSize !== undefined && item.textSize !== null && item.textSize !== '') {
|
|||
|
|
return item.textSize;
|
|||
|
|
}
|
|||
|
|
return props.textSize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取凸起按钮外层元素的left值,让其水平居中
|
|||
|
|
*/
|
|||
|
|
function getMidButtonLeft() {
|
|||
|
|
const windowWidth = $u.sys().windowWidth;
|
|||
|
|
// 由于安卓中css计算left: 50%的结果不准确,故用js计算
|
|||
|
|
midButtonLeft.value = windowWidth / 2 + 'px';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 图标和文字间距
|
|||
|
|
*/
|
|||
|
|
function containerStyle(index: number) {
|
|||
|
|
const style: Record<string, any> = {};
|
|||
|
|
const item = props.list[index] || {};
|
|||
|
|
// #ifndef APP-PLUS
|
|||
|
|
if (item.gap !== undefined && item.gap !== null && item.gap !== '') {
|
|||
|
|
style.gap = $u.addUnit(item.gap);
|
|||
|
|
} else {
|
|||
|
|
style.gap = $u.addUnit(props.gap);
|
|||
|
|
}
|
|||
|
|
// #endif
|
|||
|
|
// 如果是中间凸起按钮,为容器增加上内边距,避免文字被绝对定位的图标遮挡
|
|||
|
|
if (props.midButton && item.midButton) {
|
|||
|
|
const iconSizeRaw = getIconSize(index);
|
|||
|
|
const numericSize = parseFloat(String(iconSizeRaw)) || parseFloat(String(props.midButtonSize as any)) || 100;
|
|||
|
|
// paddingTop: 半个图标高度 + 10rpx 的缓冲间距
|
|||
|
|
style.paddingTop = $u.addUnit(numericSize / 2 + 8);
|
|||
|
|
style.boxSizing = 'border-box';
|
|||
|
|
}
|
|||
|
|
return $u.toStyle(style);
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
@import '../../libs/css/style.components.scss';
|
|||
|
|
.u-fixed-placeholder {
|
|||
|
|
/* #ifndef APP-NVUE */
|
|||
|
|
box-sizing: content-box;
|
|||
|
|
/* #endif */
|
|||
|
|
height: 50px;
|
|||
|
|
}
|
|||
|
|
.u-tabbar {
|
|||
|
|
&__content {
|
|||
|
|
@include vue-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
position: relative;
|
|||
|
|
position: fixed;
|
|||
|
|
bottom: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
z-index: 998;
|
|||
|
|
/* #ifndef APP-NVUE */
|
|||
|
|
box-sizing: content-box;
|
|||
|
|
/* #endif */
|
|||
|
|
&__circle__border {
|
|||
|
|
border-radius: 100%;
|
|||
|
|
width: 130rpx;
|
|||
|
|
height: 130rpx;
|
|||
|
|
top: -58rpx;
|
|||
|
|
position: absolute;
|
|||
|
|
z-index: 4;
|
|||
|
|
background-color: var(--u-bg-white);
|
|||
|
|
// 由于安卓的无能,导致只有3个tabbar item时,此css计算方式有误差
|
|||
|
|
// 故使用js计算的形式来定位,此处不注释,是因为js计算有延后,避免出现位置闪动
|
|||
|
|
left: 50%;
|
|||
|
|
transform: translateX(-50%);
|
|||
|
|
&:after {
|
|||
|
|
border-radius: 100px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
&__item {
|
|||
|
|
flex: 1;
|
|||
|
|
justify-content: center;
|
|||
|
|
height: 100%;
|
|||
|
|
padding: 12rpx 0;
|
|||
|
|
@include vue-flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
position: relative;
|
|||
|
|
&__container {
|
|||
|
|
@include vue-flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
height: 100%;
|
|||
|
|
width: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
&__icon {
|
|||
|
|
position: relative;
|
|||
|
|
@include vue-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
&__text {
|
|||
|
|
color: $u-content-color;
|
|||
|
|
font-size: 26rpx;
|
|||
|
|
line-height: 28rpx;
|
|||
|
|
text-align: center;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
&__circle {
|
|||
|
|
position: relative;
|
|||
|
|
@include vue-flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
z-index: 10;
|
|||
|
|
/* #ifndef APP-NVUE */
|
|||
|
|
height: calc(100% - 1px);
|
|||
|
|
/* #endif */
|
|||
|
|
&__container {
|
|||
|
|
@include vue-flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
height: 100%;
|
|||
|
|
width: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
&__icon {
|
|||
|
|
width: 100rpx;
|
|||
|
|
height: 100rpx;
|
|||
|
|
border-radius: 100%;
|
|||
|
|
@include vue-flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
background-color: var(--u-bg-white);
|
|||
|
|
/* 将凸起图标上移,与顶部边框线对齐 */
|
|||
|
|
position: absolute;
|
|||
|
|
top: -55rpx;
|
|||
|
|
left: 50%;
|
|||
|
|
z-index: 6;
|
|||
|
|
transform: translateX(-50%);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|