347 lines
11 KiB
Vue
347 lines
11 KiB
Vue
<template>
|
||
<view class="l-tabs">
|
||
<view class="l-tabs__wrap" :style="[styles, lStyle]">
|
||
<slot name="left"></slot>
|
||
<scroll-view class="l-tabs__scroll" :class="{'l-tabs__scroll--split': split}" enhanced enable-flex
|
||
:scroll-left="scrollLeft" scroll-x scroll-anchoring scroll-with-animation enable-passive
|
||
:show-scrollbar="false" @scroll="onScroll">
|
||
<view class="l-tabs__nav">
|
||
<view class="l-tabs__item"
|
||
:style="{color: !item.disabled && (index == currentIndex ? activeColor : color)}" :class="[
|
||
'l-tabs__item-text--' + size,
|
||
{
|
||
|
||
'l-tabs__item--active': index == currentIndex,
|
||
'l-tabs__item--evenly': spaceEvenly,
|
||
'l-tabs__item--disabled': item.disabled
|
||
}
|
||
]" v-for="(item, index) in tabs" :key="index" @tap="onClick(index, item)" aria-role="tab">
|
||
<slot name="label" :item="item" :active="index == currentIndex" :disabled="item.disabled">
|
||
<l-badge v-if="item.dot == true || item.badge" :dot="item.dot" :offset="item.offset"
|
||
:content="item.badge">
|
||
<view class="l-tabs__item-text l-ellipsis">
|
||
{{item.label}}
|
||
</view>
|
||
</l-badge>
|
||
<view v-else class="l-tabs__item-text l-ellipsis">
|
||
{{item.label}}
|
||
</view>
|
||
</slot>
|
||
|
||
</view>
|
||
<view class="l-tabs__track" :style="[trackStyle]"></view>
|
||
</view>
|
||
</scroll-view>
|
||
<slot name="right"></slot>
|
||
</view>
|
||
<view class="l-tabs__content" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"
|
||
@touchcancel="onTouchEnd" :class="{'l-tabs__content--animated': animated}" v-if="$slots['default']">
|
||
<view class="l-tabs__content-inner" :style="[innerStyle]" ref="innerRef">
|
||
<slot></slot>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
<script lang="ts">
|
||
// @ts-nocheck
|
||
/**
|
||
* Tabs 标签页组件
|
||
* @description 用于内容分类展示和切换,支持多种样式和交互效果
|
||
* <br>插件类型:LTabsComponentPublicInstance
|
||
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-tabs
|
||
*
|
||
* @property {TabPanel[]} list 选项卡配置数组
|
||
* @property {boolean} ellipsis 是否省略超长文本(默认:true)
|
||
* @property {boolean} animated 是否启用切换动画(默认:false)
|
||
* @property {number} duration 动画时长(ms,默认:300)
|
||
* @property {boolean} showLine 显示底部激活线条(默认:true)
|
||
* @property {'medium' | 'large'} size 组件尺寸(默认:"medium")
|
||
* @value medium
|
||
* @value large
|
||
* @property {boolean} spaceEvenly 均分选项卡宽度(默认:false)
|
||
* @property {boolean} swipeable 支持滑动切换(默认:true)
|
||
* @property {number} value 当前激活选项卡索引(支持v-model)
|
||
* @property {string} color 默认文字颜色(默认:"#333")
|
||
* @property {string} activeColor 激活状态颜色(默认:主题色)
|
||
* @property {string} lineColor 激活线条颜色(默认:跟随activeColor)
|
||
* @property {string} lineWidth 激活线条宽度(默认:"20px")
|
||
* @property {string} lineHeight 激活线条高度(默认:"3px")
|
||
* @property {string} bgColor 背景颜色
|
||
* @property {string} fontSize 文字大小(支持CSS单位)
|
||
* @property {string} padding 内边距(支持CSS简写)
|
||
* @property {boolean} split 显示分隔线(默认:false)
|
||
* @property {boolean} visible 是否显示组件(默认:true)
|
||
* @event {Function} change 选项卡切换时触发(返回激活索引)
|
||
* @event {Function} click 点击时触发(返回激活索引)
|
||
*/
|
||
|
||
|
||
import { defineComponent, ref, provide, reactive, getCurrentInstance, watch, watchEffect, computed, type ComputedRef, onUnmounted, nextTick, onMounted } from '@/uni_modules/lime-shared/vue';
|
||
import tabsProps from './props'
|
||
import { TabPanel } from './type';
|
||
import { getRect, getAllRect } from '@/uni_modules/lime-shared/getRect'
|
||
import { clamp } from '@/uni_modules/lime-shared/clamp'
|
||
import { calcScrollOffset, ease, calculateTabPositions } from './utils'
|
||
import { useTouch } from './touch';
|
||
// import { onReady } from '@dcloudio/uni-app'
|
||
export default defineComponent({
|
||
name: 'l-tabs',
|
||
props: tabsProps,
|
||
options: {
|
||
addGlobalClass: true,
|
||
virtualHost: true,
|
||
},
|
||
emits: ['update:modelValue', 'change', 'input', 'click', 'total-width'],
|
||
setup(props, { emit }) {
|
||
const context = getCurrentInstance().proxy!
|
||
const scrollLeft = ref(0)
|
||
const lastLeft = ref(0)
|
||
const trackDistance = ref(0)
|
||
const children = ref([])
|
||
const innerStyle = reactive({
|
||
width: '',
|
||
'transition-duration': ``,
|
||
'transform': ``,
|
||
})
|
||
const defaultValue = ref(props.value ?? props.modelValue ?? 0)
|
||
const currentValue = computed({
|
||
set(value : number) {
|
||
defaultValue.value = value
|
||
emit('update:modelValue', value)
|
||
emit('change', value)
|
||
// #ifdef VUE2
|
||
emit('input', value)
|
||
// #endif
|
||
},
|
||
get() : number {
|
||
return props.value ?? props.modelValue ?? defaultValue.value
|
||
}
|
||
} as WritableComputedOptions<number>);
|
||
|
||
const styles = computed(() => {
|
||
const style : Record<string, any> = {};
|
||
if (props.bgColor) {
|
||
style['--l-tab-nav-bg-color'] = props.bgColor
|
||
}
|
||
if (!['medium', 'large'].includes(props.size)) {
|
||
style['--l-tab-item-height'] = props.size
|
||
}
|
||
if (props.padding) {
|
||
style['--l-tab-item-padding'] = props.padding
|
||
}
|
||
if (props.fontSize) {
|
||
style['--l-tab-font-size'] = props.fontSize
|
||
}
|
||
return style
|
||
})
|
||
|
||
const trackStyle = computed(() : Map<string, any> => {
|
||
const style : Record<string, any> = {
|
||
'-webkit-transform': `translateX(${trackDistance.value}px)`,
|
||
'transform': `translateX(${trackDistance.value}px)`,
|
||
};
|
||
if (props.lineColor) {
|
||
style['background'] = props.lineColor!
|
||
}
|
||
if (props.lineWidth) {
|
||
style['width'] = props.lineWidth!
|
||
}
|
||
if (props.lineHeight) {
|
||
style['height'] = props.lineHeight!
|
||
}
|
||
return style
|
||
})
|
||
|
||
const tabs : ComputedRef<TabPanel[]> = computed(() => {
|
||
if (props.list) return props.list as TabPanel[]
|
||
return children.value
|
||
})
|
||
|
||
const currentIndex = computed(() => {
|
||
const index = tabs.value.findIndex((child, index) => (child.value || index) == currentValue.value)
|
||
if (index >= 0) return index
|
||
return 0
|
||
})
|
||
|
||
const tabRects = ref<DOMRect[]>([]) // 存储每个tab的尺寸位置
|
||
|
||
const totalTabsWidth = computed(() : number => {
|
||
if (tabRects.value.length == 0) return 0
|
||
return tabRects.value.reduce((sum : number, rect : DOMRect) : number => sum + rect.width, 0)
|
||
})
|
||
const moveToActiveTab = () => {
|
||
nextTick(() => {
|
||
try {
|
||
Promise.all([
|
||
getRect(`.l-tabs__scroll`, context),
|
||
getRect(`.l-tabs__track`, context),
|
||
getAllRect(`.l-tabs__item`, context)
|
||
]).then(([scrollRect, trackRect, tabsRects]) => {
|
||
const index = currentIndex.value
|
||
const tabRect = tabsRects[index];
|
||
tabRects.value = tabsRects
|
||
if (!tabRect) return;
|
||
|
||
let [distance] = calculateTabPositions(tabsRects, index)
|
||
|
||
if (totalTabsWidth.value == 0) return
|
||
const offset = calcScrollOffset(scrollRect.width, tabRect.left, tabRect.width, lastLeft.value);
|
||
const maxOffset = totalTabsWidth.value - scrollRect.width;
|
||
scrollLeft.value = clamp(offset, 0, maxOffset)
|
||
|
||
// distance += (tabRect.width - trackRect.width) / 2;
|
||
trackDistance.value = distance + (tabRect.width - trackRect.width) / 2;
|
||
})
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
})
|
||
}
|
||
const updateInnerStyle = (offset : number) => {
|
||
if (props.list && props.list.length > 0) return
|
||
nextTick(() => {
|
||
Promise.all(
|
||
[
|
||
getRect('.l-tabs__content', context),
|
||
getRect('.l-tabs__content-inner', context)
|
||
]).then(([parent, inner]) => {
|
||
const left = -parent.width * currentIndex.value + offset;
|
||
if (offset != 0) {
|
||
innerStyle.transform = `translateX(${left}px)`
|
||
innerStyle['transition-duration'] = `0s`
|
||
} else {
|
||
if (props.animated) {
|
||
innerStyle['transition-duration'] = offset != 0 || !props.animated ? '0s' : `${props.duration}s`
|
||
}
|
||
innerStyle.transform = `translateX(${left}px)`
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
|
||
const onScroll = (e) => {
|
||
lastLeft.value = e.detail.scrollLeft
|
||
}
|
||
|
||
const onClick = (index : number, item : TabPanel) => {
|
||
const { value = index, disabled, label } = item;
|
||
if (disabled || currentValue.value === value) { return }
|
||
currentValue.value = value
|
||
emit('click', value)
|
||
moveToActiveTab()
|
||
}
|
||
const getAvailableTabIndex = (deltaX : number) : number => {
|
||
const step = deltaX > 0 ? -1 : 1;
|
||
const len = tabs.value.length;
|
||
for (let i = step; currentIndex.value + step >= 0 && currentIndex.value + step < len; i += step) {
|
||
const newIndex = currentIndex.value + i;
|
||
|
||
if (newIndex >= 0 && newIndex < len && tabs.value.length > newIndex && !tabs.value[newIndex].disabled) {
|
||
return newIndex;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
const touch = useTouch();
|
||
const onTouchStart = (event : UniTouchEvent) => {
|
||
if (!props.swipeable) return;
|
||
touch.start(event);
|
||
}
|
||
const onTouchMove = (event : UniTouchEvent) => {
|
||
if (!props.swipeable) return;
|
||
touch.move(event);
|
||
const { direction, deltaX, startX } = touch
|
||
if (direction.value != 'horizontal') return
|
||
if (!props.animated) return
|
||
const isAtFirstTab = currentIndex.value == 0;
|
||
const isAtLastTab = currentIndex.value == tabs.value.length - 1;
|
||
|
||
if ((isAtFirstTab && deltaX.value > 0) || (isAtLastTab && deltaX.value < 0)) {
|
||
const base = isAtFirstTab ? 1 : -1;
|
||
const adjustedDelta = ease(deltaX.value, base);
|
||
updateInnerStyle(adjustedDelta);
|
||
} else {
|
||
updateInnerStyle(deltaX.value);
|
||
}
|
||
}
|
||
const onTouchEnd = () => {
|
||
if (!props.swipeable) return;
|
||
const { direction, deltaX, offsetX } = touch
|
||
const minSwipeDistance = 50;
|
||
if (direction.value == 'horizontal' && offsetX.value >= minSwipeDistance) {
|
||
const index = getAvailableTabIndex(deltaX.value);
|
||
if (index != -1) {
|
||
onClick(index, tabs.value[index])
|
||
}
|
||
}
|
||
updateInnerStyle(0)
|
||
}
|
||
|
||
watchEffect(() => {
|
||
// console.log('tabs', tabs.value)
|
||
if (tabs.value.length == 0) return
|
||
moveToActiveTab()
|
||
})
|
||
// const stopWatch = watch(tabs, (v) => {
|
||
// moveToActiveTab()
|
||
// })
|
||
const stopValueWatch = watch(currentValue, (_v : number) => {
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
})
|
||
|
||
const stopVisibleWatch = watch(() => props.visible, (v) => {
|
||
if (!v) return
|
||
setTimeout(() => {
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
}, 100)
|
||
})
|
||
|
||
|
||
// 添加监听器,当总宽度变化时emit事件
|
||
watch(totalTabsWidth, (newWidth : number, oldWidth : number) => {
|
||
if (newWidth !== oldWidth && newWidth > 0) {
|
||
emit('total-width', newWidth)
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
setTimeout(() => {
|
||
if (tabs.value.length == 0) return
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
}, 100)
|
||
})
|
||
})
|
||
onUnmounted(() => {
|
||
// stopWatch()
|
||
stopVisibleWatch()
|
||
stopValueWatch()
|
||
})
|
||
|
||
|
||
provide('LimeTabs', children)
|
||
return {
|
||
styles,
|
||
|
||
tabs,
|
||
scrollLeft,
|
||
currentValue,
|
||
currentIndex,
|
||
trackStyle,
|
||
innerStyle,
|
||
onScroll,
|
||
onClick,
|
||
onTouchStart,
|
||
onTouchMove,
|
||
onTouchEnd
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
<style lang="scss">
|
||
@import './index';
|
||
</style> |