<template>
|
<view
|
ref="container"
|
class="nut-tabs"
|
:class="[direction, { fullHeight, noContent: !showPaneContent, flexTitle }]"
|
>
|
<div :class="['pro-tabs__titles_wrapper', { isTransparent }]">
|
<scroll-view
|
:id="`nut-tabs__titles_${name}`"
|
:scroll-x="getScrollX"
|
:scroll-y="getScrollY"
|
:scroll-with-animation="true"
|
:scroll-left="scrollLeft"
|
:scroll-top="scrollTop"
|
:enable-flex="true"
|
class="nut-tabs__titles tabs-scrollview"
|
:class="{
|
[type]: type,
|
scrollable: titleScroll,
|
'scroll-vertical': getScrollY,
|
[size]: size,
|
}"
|
:style="tabsNavStyle"
|
enhanced
|
:show-scrollbar="false"
|
@scroll="onScroll"
|
>
|
<view class="nut-tabs__list">
|
<slot v-if="$slots.titles" name="titles"></slot>
|
<template v-else>
|
<view
|
v-for="(item, index) in titles"
|
:key="item.paneKey"
|
class="nut-tabs__titles-item taro"
|
:style="titleStyle"
|
:class="{ active: item.paneKey == modelValue, disabled: item.disabled }"
|
@click="tabMethods.tabChange(item, index)"
|
>
|
<view
|
v-if="type == 'line'"
|
class="nut-tabs__titles-item__line"
|
:style="tabsActiveStyle"
|
></view>
|
<view
|
v-if="type == 'smile'"
|
class="nut-tabs__titles-item__smile"
|
:style="tabsActiveStyle"
|
>
|
<JoySmile :color="color" />
|
</view>
|
<nut-badge
|
right="-12"
|
top="4"
|
:value="item.badgeValue"
|
:max="99"
|
:hidden="!item.badgeValue"
|
color="#FC0B0B"
|
>
|
<view class="nut-tabs__titles-item__text" :class="{ ellipsis: ellipsis }"
|
>{{ item.title }}
|
</view>
|
</nut-badge>
|
<view class="nut-tabs__sub-titles-item__text" :class="{ ellipsis: ellipsis }"
|
>{{ item.subTitle }}
|
</view>
|
</view>
|
<view
|
v-if="canShowLabel"
|
class="nut-tabs__titles-item nut-tabs__titles-placeholder"
|
></view>
|
</template>
|
</view>
|
</scroll-view>
|
<slot name="right"></slot>
|
</div>
|
|
<view
|
:id="'tabsContentRef-' + refRandomId"
|
ref="tabsContentRef"
|
class="nut-tabs__content"
|
:style="contentStyle"
|
@touchstart="touchMethods.onTouchStart"
|
@touchmove="touchMethods.onTouchMove"
|
@touchend="touchMethods.onTouchEnd"
|
@touchcancel="touchMethods.onTouchEnd"
|
v-show="showPaneContent"
|
>
|
<slot name="default"></slot>
|
</view>
|
</view>
|
</template>
|
|
<script setup lang="ts">
|
import { JoySmile } from '@nutui/icons-vue-taro';
|
import { tabsProps, Title, TypeOfFun, RectItem, raf, pxCheck, useTabContentTouch } from './tabs';
|
import Taro from '@tarojs/taro';
|
import {
|
CSSProperties,
|
useSlots,
|
computed,
|
ref,
|
onMounted,
|
provide,
|
VNode,
|
Ref,
|
nextTick,
|
watch,
|
onActivated,
|
} from 'vue';
|
import { useTaroRect, useScrollDistance } from 'senin-mini/hooks';
|
|
defineOptions({
|
name: 'ProTabs',
|
});
|
|
const props = defineProps(tabsProps);
|
|
const emit = defineEmits<{
|
(e: 'update:modelValue', value: string | number): void;
|
(e: 'click', item: Title): void;
|
(e: 'change', item: Title): void;
|
}>();
|
|
const slots = useSlots();
|
|
const container = ref(null);
|
provide('tabsOpiton', {
|
activeKey: computed(() => props.modelValue || '0'),
|
autoHeight: computed(() => props.autoHeight),
|
animatedTime: computed(() => props.animatedTime),
|
});
|
const titles: Ref<Title[]> = ref([]);
|
const renderTitles = (vnodes: VNode[]) => {
|
vnodes.forEach((vnode: VNode, index: number) => {
|
let type = vnode?.type;
|
type = (type as any).name || type;
|
if (type === 'nut-tab-pane' || type === 'ProTabPane') {
|
let title = new Title();
|
if (vnode.props?.title || vnode.props?.['pane-key'] || vnode.props?.['paneKey']) {
|
let paneKeyType = TypeOfFun(vnode.props?.['pane-key']);
|
let paneIndex =
|
paneKeyType === 'number' || paneKeyType === 'string'
|
? String(vnode.props?.['pane-key'])
|
: null;
|
let camelPaneKeyType = TypeOfFun(vnode.props?.['paneKey']);
|
let camelPaneIndex =
|
camelPaneKeyType === 'number' || camelPaneKeyType === 'string'
|
? String(vnode.props?.['paneKey'])
|
: null;
|
title.title = vnode.props?.title;
|
title.subTitle = vnode.props?.subTitle;
|
title.badgeValue = vnode.props?.badgeValue;
|
title.paneKey = paneIndex || camelPaneIndex || String(index);
|
title.disabled = vnode.props?.disabled;
|
} else {
|
// title.titleSlot = vnode.children?.title() as VNode[];
|
}
|
titles.value.push(title);
|
} else {
|
if (vnode.children === ' ') {
|
return;
|
}
|
renderTitles(vnode.children as VNode[]);
|
}
|
});
|
};
|
|
const currentIndex = ref((props.modelValue as number) || 0);
|
const findTabsIndex = (value: string | number) => {
|
let index = titles.value.findIndex((item) => item.paneKey === value);
|
if (titles.value.length === 0) {
|
// console.warn('[NutUI] <Tabs> 当前未找到 TabPane 组件元素 , 请检查 .');
|
} else if (index === -1) {
|
// console.warn('[NutUI] <Tabs> 请检查 v-model 值是否为 paneKey ,如 paneKey 未设置,请采用下标控制 .');
|
} else {
|
currentIndex.value = index;
|
}
|
};
|
const getScrollX = computed(() => {
|
return props.titleScroll && props.direction === 'horizontal';
|
});
|
const getScrollY = computed(() => {
|
return props.titleScroll && props.direction === 'vertical';
|
});
|
const titleRef = ref([]) as Ref<HTMLElement[]>;
|
// const scrollLeft = ref(0);
|
const scrollTop = ref(0);
|
const scrollWithAnimation = ref(false);
|
const getRect = (selector: string) => {
|
return new Promise((resolve) => {
|
Taro.createSelectorQuery()
|
.select(selector)
|
.boundingClientRect()
|
.exec((rect = []) => {
|
resolve(rect[0]);
|
});
|
});
|
};
|
const getAllRect = (selector: string) => {
|
return new Promise((resolve) => {
|
Taro.createSelectorQuery()
|
.selectAll(selector)
|
.boundingClientRect()
|
.exec((rect = []) => resolve(rect[0]));
|
});
|
};
|
const navRectRef = ref();
|
const titleRectRef = ref<RectItem[]>([]);
|
const canShowLabel = ref(false);
|
const scrollIntoView = () => {
|
if (!props.name) return;
|
|
raf(() => {
|
Promise.all([
|
getRect(`#nut-tabs__titles_${props.name}`),
|
getAllRect(`#nut-tabs__titles_${props.name} .nut-tabs__titles-item`),
|
]).then(([navRect, titleRects]: any) => {
|
navRectRef.value = navRect;
|
titleRectRef.value = titleRects;
|
|
if (navRectRef.value) {
|
if (props.direction === 'vertical') {
|
const titlesTotalHeight = titleRects.reduce(
|
(prev: number, curr: RectItem) => prev + curr.height,
|
0
|
);
|
if (titlesTotalHeight > navRectRef.value.height) {
|
canShowLabel.value = true;
|
} else {
|
canShowLabel.value = false;
|
}
|
} else {
|
const titlesTotalWidth = titleRects.reduce(
|
(prev: number, curr: RectItem) => prev + curr.width,
|
0
|
);
|
if (titlesTotalWidth > navRectRef.value.width) {
|
canShowLabel.value = true;
|
} else {
|
canShowLabel.value = false;
|
}
|
}
|
}
|
|
const titleRect: RectItem = titleRectRef.value[currentIndex.value];
|
|
let to = 0;
|
if (props.direction === 'vertical') {
|
const DEFAULT_PADDING = 11;
|
const top = titleRects
|
.slice(0, currentIndex.value)
|
.reduce((prev: number, curr: RectItem) => prev + curr.height + 0, DEFAULT_PADDING);
|
to = top - (navRectRef.value.height - titleRect.height) / 2;
|
} else {
|
const DEFAULT_PADDING = 31;
|
const left = titleRects
|
.slice(0, currentIndex.value)
|
.reduce((prev: number, curr: RectItem) => prev + curr.width + 20, DEFAULT_PADDING);
|
to = left - (navRectRef.value?.width ?? 0 - titleRect?.width ?? 0) / 2;
|
}
|
|
nextTick(() => {
|
scrollWithAnimation.value = true;
|
});
|
|
scrollDirection(to, props.direction);
|
});
|
});
|
};
|
|
const {
|
onScroll,
|
scrollDistance: scrollLeft,
|
setScrollDistance: setScrollLeft,
|
} = useScrollDistance({ direction: 'horizontal' });
|
|
const scrollDirection = (to: number, direction: 'horizontal' | 'vertical') => {
|
let count = 0;
|
const from = direction === 'horizontal' ? scrollLeft.value : scrollTop.value;
|
const frames = 1;
|
|
function animate() {
|
if (direction === 'horizontal') {
|
scrollLeft.value = Math.max(0.1, to);
|
} else {
|
scrollTop.value += (to - from) / frames;
|
}
|
|
if (++count < frames) {
|
raf(animate);
|
}
|
}
|
|
if (direction === 'horizontal') {
|
setScrollLeft(Math.max(0.1, to));
|
} else {
|
animate();
|
}
|
};
|
const init = (vnodes: VNode[] = slots.default?.()) => {
|
titles.value = [];
|
vnodes = vnodes?.filter((item) => typeof item.children !== 'string');
|
if (vnodes && vnodes.length) {
|
renderTitles(vnodes);
|
}
|
findTabsIndex(props.modelValue);
|
setTimeout(() => {
|
scrollIntoView();
|
}, 500);
|
};
|
|
watch(
|
() => slots.default?.(),
|
(vnodes: VNode[]) => {
|
init(vnodes);
|
}
|
);
|
watch(
|
() => props.modelValue,
|
(value: string | number) => {
|
findTabsIndex(value);
|
setTimeout(() => {
|
scrollIntoView();
|
}, 500);
|
}
|
);
|
onMounted(init);
|
onActivated(init);
|
const tabMethods = {
|
isBegin: () => {
|
return currentIndex.value === 0;
|
},
|
isEnd: () => {
|
return currentIndex.value === titles.value.length - 1;
|
},
|
next: () => {
|
currentIndex.value += 1;
|
tabMethods.updateValue(titles.value[currentIndex.value]);
|
},
|
prev: () => {
|
currentIndex.value -= 1;
|
tabMethods.updateValue(titles.value[currentIndex.value]);
|
},
|
updateValue: (item: Title) => {
|
emit('update:modelValue', item.paneKey);
|
emit('change', item);
|
},
|
tabChange: (item: Title, index: number) => {
|
emit('click', item);
|
if (item.disabled || currentIndex.value === index) {
|
return;
|
}
|
currentIndex.value = index;
|
tabMethods.updateValue(item);
|
},
|
setTabItemRef: (el: HTMLElement, index: number) => {
|
titleRef.value[index] = el;
|
},
|
};
|
const { tabsContentRef, touchState, touchMethods } = useTabContentTouch(
|
props,
|
tabMethods,
|
Taro,
|
useTaroRect
|
);
|
const contentStyle = computed(() => {
|
let offsetPercent = currentIndex.value * 100;
|
if (touchState.moving) {
|
offsetPercent += touchState.offset;
|
}
|
let style: CSSProperties = {
|
transform:
|
props.direction === 'horizontal'
|
? `translate3d(-${offsetPercent}%, 0, 0)`
|
: `translate3d( 0,-${offsetPercent}%, 0)`,
|
transitionDuration: touchState.moving ? undefined : `${props.animatedTime}ms`,
|
};
|
if (props.animatedTime === 0) {
|
style = {};
|
}
|
return style;
|
});
|
const tabsNavStyle = computed(() => {
|
return {
|
background: props.background,
|
};
|
});
|
const tabsActiveStyle = computed(() => {
|
return {
|
color: props.type === 'smile' ? props.color : '',
|
background: props.type === 'line' ? props.color : '',
|
};
|
});
|
const titleStyle = computed(() => {
|
if (!props.titleGutter) return {};
|
const px = pxCheck(props.titleGutter);
|
if (props.direction === 'vertical') {
|
return { marginTop: px, marginBottom: px };
|
}
|
return { marginLeft: px, marginRight: px };
|
});
|
const refRandomId = Math.random().toString(36).slice(-8);
|
</script>
|
|
<style lang="scss">
|
@import '@/styles/common.scss';
|
|
.pro-tabs__titles_wrapper {
|
display: flex;
|
align-items: center;
|
background-color: #fff;
|
position: relative;
|
z-index: 10;
|
box-shadow: none;
|
padding: 0 boleGetCssVar('size', 'body-padding-h');
|
margin-bottom: 8px;
|
|
&.isTransparent {
|
background-color: transparent;
|
margin-bottom: 0;
|
}
|
}
|
|
.nut-tabs.noContent {
|
box-shadow: none;
|
position: relative;
|
z-index: 9;
|
}
|
|
.flexTitle {
|
box-shadow: none;
|
position: relative;
|
z-index: 9;
|
|
.pro-tabs__titles_wrapper {
|
padding: 0;
|
|
.nut-tabs__titles.tabs-scrollview .nut-tabs__list .nut-tabs__titles-item {
|
flex: 1;
|
margin: 0;
|
min-width: 0;
|
flex-shrink: 0;
|
}
|
}
|
}
|
|
.nut-tabs__titles.tabs-scrollview {
|
background-color: transparent;
|
flex: 1;
|
min-width: 0;
|
box-sizing: border-box;
|
height: 104rpx;
|
@include hiddenScrollBar;
|
|
.nut-tabs__list {
|
height: 104rpx;
|
justify-content: flex-start;
|
|
.nut-tabs__titles-item {
|
flex: none;
|
width: auto;
|
margin-right: 78rpx;
|
flex-direction: column;
|
font-size: 30rpx;
|
color: boleGetCssVar('text-color', 'regular');
|
align-items: center;
|
transition: all 0.3s ease;
|
min-width: 0;
|
|
.nut-tabs__sub-titles-item__text {
|
font-size: 20rpx;
|
color: boleGetCssVar('text-color', 'regular');
|
}
|
|
.nut-tabs__titles-item__text {
|
font-size: 30rpx;
|
color: boleGetCssVar('text-color', 'regular');
|
line-height: 40rpx;
|
}
|
|
&.active {
|
color: boleGetCssVar('color', 'primary');
|
font-weight: normal;
|
font-size: 30rpx;
|
|
.nut-tabs__sub-titles-item__text {
|
color: boleGetCssVar('color', 'primary');
|
}
|
|
.nut-tabs__titles-item__line {
|
width: 100%;
|
}
|
|
.nut-tabs__titles-item__text {
|
color: boleGetCssVar('color', 'primary');
|
}
|
}
|
|
&:last-child {
|
margin-right: 0;
|
}
|
|
&:first-child {
|
margin-left: 0 !important;
|
}
|
|
.nut-tabs__titles-item__line {
|
// width: 100%;
|
background: boleGetCssVar('color', 'primary');
|
bottom: 20rpx;
|
height: 4rpx;
|
max-width: 40rpx;
|
}
|
}
|
}
|
}
|
|
.nut-tabs.fullHeight {
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
|
.nut-tabs__content {
|
flex: 1;
|
min-height: 0;
|
|
.nut-tab-pane {
|
padding: 0;
|
background-color: transparent;
|
overflow: hidden;
|
display: flex;
|
flex-direction: column;
|
}
|
}
|
}
|
</style>
|