612 lines
18 KiB
Plaintext
612 lines
18 KiB
Plaintext
<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}"
|
||
<!-- #ifdef APP || WEB -->
|
||
ref="scrollRef"
|
||
<!-- #endif -->
|
||
:scroll-left="scrollLeft"
|
||
direction="horizontal"
|
||
:scroll-x="true"
|
||
:scroll-with-animation="true"
|
||
:show-scrollbar="false"
|
||
:enhanced="true"
|
||
@scroll="onScroll">
|
||
<!-- #ifndef APP -->
|
||
<view ref="navRef" class="l-tabs__nav">
|
||
<!-- #endif -->
|
||
<view class="l-tabs__item" v-for="(item, index) in tabs" :key="index" :style="[itemStyle]" :class="{
|
||
'l-tabs__item--active': index == currentIndex,
|
||
'l-tabs__item--evenly': spaceEvenly,
|
||
'l-tabs__item--disabled': item.disabled}" @click="onClick(index, item)">
|
||
<slot name="label" :item="item" :active="index == currentIndex" :disabled="item.disabled">
|
||
<l-badge v-if="item.dot == true || item.badge != null" :dot="item.dot" :offset="item.offset"
|
||
:content="item.badge">
|
||
<text :style="[
|
||
textStyles,
|
||
!item.disabled && color != null && index != currentIndex ? ('color:' + color) : '',
|
||
!item.disabled && activeColor != null && index == currentIndex ? 'color:' + activeColor : '',
|
||
]" class="l-tabs__item-text" :class="[
|
||
'l-tabs__item-text--' + size,
|
||
{
|
||
'l-tabs__item-text--disabled': item.disabled,
|
||
'l-tabs__item-text--active': index == currentIndex
|
||
}
|
||
]">{{item.label}}</text>
|
||
</l-badge>
|
||
<text v-else :style="[
|
||
textStyles,
|
||
!item.disabled && color != null && index != currentIndex ? ('color:' + color) : '',
|
||
!item.disabled && activeColor != null && index == currentIndex ? 'color:' + activeColor : '',
|
||
]" class="l-tabs__item-text" :class="[
|
||
'l-tabs__item-text--' + size,
|
||
{
|
||
'l-tabs__item-text--disabled': item.disabled,
|
||
'l-tabs__item-text--active': index == currentIndex
|
||
}
|
||
]">{{item.label}}</text>
|
||
</slot>
|
||
</view>
|
||
<view ref="trackRef" :style="[trackStyle]" class="l-tabs__track"></view>
|
||
<!-- #ifndef APP -->
|
||
</view>
|
||
<!-- #endif -->
|
||
</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'] != null">
|
||
<!-- #ifdef APP || WEB -->
|
||
<view class="l-tabs__content-inner" :style="[innerStyle]" ref="innerRef">
|
||
<slot></slot>
|
||
</view>
|
||
<!-- #endif -->
|
||
<!-- #ifndef APP || WEB -->
|
||
<view class="l-tabs__content-inner" :style="[innerStyle, {width: tabs.length * 100 + '%'}]" ref="innerRef">
|
||
<slot></slot>
|
||
</view>
|
||
<!-- #endif -->
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="uts" setup>
|
||
/**
|
||
* 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)
|
||
* @property {number} swiperProgress 与swiper联动,传入[-1, 1] 范围的滚动进度
|
||
* @property {boolean} syncSwiper 是否启用与swiper的同步联动
|
||
* @event {Function} change 选项卡切换时触发(返回激活索引)
|
||
* @event {Function} click 点击时触发(返回激活索引)
|
||
*/
|
||
|
||
import { TabsProps, TabPanel } from './type';
|
||
import { TabPanelProps } from '../l-tab-panel/type';
|
||
import { calcScrollOffset, ease, calculateTabPositions } from './utils';
|
||
import { clamp } from '@/uni_modules/lime-shared/clamp';
|
||
// import { getRect } from '@/uni_modules/lime-shared/getRect';
|
||
import { getRect, getAllRect } from '@/uni_modules/lime-shared/getRect'
|
||
import { useTouch } from './touch'
|
||
const emits = defineEmits(['change', 'click', 'total-width'])
|
||
// const slots = defineSlots<{
|
||
// label : {
|
||
// item : TabPanel,
|
||
// active : boolean,
|
||
// disabled : boolean,
|
||
// }
|
||
// }>()
|
||
|
||
const slots = defineSlots<{
|
||
label(props : { item : TabPanel, active : boolean, disabled : boolean }) : any,
|
||
default(props : {}) : any,
|
||
left(props : {}) : any,
|
||
right(props : {}) : any,
|
||
}>()
|
||
|
||
const props = withDefaults(defineProps<TabsProps>(), {
|
||
showLine: true,
|
||
visible: false,
|
||
ellipsis: false,
|
||
size: 'medium',
|
||
spaceEvenly: true,
|
||
swipeable: false,
|
||
duration: 0.3,
|
||
animated: false,
|
||
split: true,
|
||
swiperProgress: 0,
|
||
syncSwiper: false
|
||
})
|
||
|
||
const children = ref<LTabPanelComponentPublicInstance[]>([])
|
||
const scrollLeft = ref(0);
|
||
const lastLeft = ref(0);
|
||
|
||
// #ifndef WEB || APP
|
||
const context = getCurrentInstance().proxy!;
|
||
const trackDistance = ref(0)
|
||
const innerStyle = reactive({
|
||
width: '',
|
||
'transition-duration': ``,
|
||
'transform': ``,
|
||
})
|
||
// #endif
|
||
// #ifdef WEB || APP
|
||
const innerStyle = {
|
||
|
||
}
|
||
// #endif
|
||
|
||
const modelValue = defineModel({ type: Number })
|
||
const currentValue = computed({
|
||
set(value : number) {
|
||
modelValue.value = value;
|
||
emits('change', value)
|
||
},
|
||
get() : number {
|
||
return props.value ?? modelValue.value
|
||
}
|
||
} as WritableComputedOptions<number>);
|
||
|
||
const styles = computed(() : Map<string, any> => {
|
||
const style = new Map<string, any>();
|
||
// #ifdef APP
|
||
if (props.bgColor != null) {
|
||
style.set('background-color', props.bgColor!)
|
||
}
|
||
// #endif
|
||
// #ifndef APP
|
||
if (props.bgColor != null) {
|
||
style.set('--l-tab-nav-bg-color', props.bgColor!)
|
||
}
|
||
if (!['medium', 'large'].includes(props.size)) {
|
||
style.set('--l-tab-item-height', props.size)
|
||
}
|
||
if (props.padding != null) {
|
||
style.set('--l-tab-item-padding', props.padding!)
|
||
}
|
||
if (props.fontSize != null) {
|
||
style.set('--l-tab-font-size', props.fontSize!)
|
||
}
|
||
// #endif
|
||
return style
|
||
})
|
||
const trackStyle = computed(() : Map<string, any> => {
|
||
const style = new Map<string, any>([
|
||
// #ifndef APP || WEB
|
||
['-webkit-transform', `translateX(${trackDistance.value}px)`],
|
||
['transform', `translateX(${trackDistance.value}px)`],
|
||
// #endif
|
||
]);
|
||
|
||
if (props.lineColor != null) {
|
||
style.set('background', props.lineColor!)
|
||
}
|
||
if (props.lineWidth != null) {
|
||
style.set('width', props.lineWidth!)
|
||
}
|
||
if (props.lineHeight != null) {
|
||
style.set('height', props.lineHeight!)
|
||
}
|
||
return style
|
||
})
|
||
const itemStyle = computed(() : Map<string, any> => {
|
||
const style = new Map<string, any>()
|
||
// #ifdef APP
|
||
if (!['medium', 'large'].includes(props.size)) {
|
||
style.set('height', props.size)
|
||
}
|
||
if (props.padding != null) {
|
||
style.set('padding', props.padding!)
|
||
}
|
||
// #endif
|
||
return style
|
||
})
|
||
const textStyles = computed(() : Map<string, any> => {
|
||
const style = new Map<string, any>()
|
||
// #ifdef APP
|
||
if (props.fontSize != null) {
|
||
style.set('font-size', props.fontSize!)
|
||
}
|
||
// #endif
|
||
return style
|
||
})
|
||
|
||
const tabs = computed(() : TabPanel[] => {
|
||
if (props.list != null && props.list!.length > 0) {
|
||
return props.list!.map((item) : TabPanel => {
|
||
return {
|
||
badge: item.get('badge'),
|
||
dot: item.getBoolean('dot') ?? false,
|
||
disabled: item.getBoolean('disabled') ?? false,
|
||
label: item.getString('label'),
|
||
offset: item.getArray('offset') as any[] | null,
|
||
value: item.getNumber('value'),
|
||
node: item
|
||
} as TabPanel
|
||
})
|
||
}
|
||
return children.value.map((item) : TabPanel => {
|
||
|
||
// 安卓端数组属性存在BUG 死循环
|
||
// #ifdef APP-ANDROID
|
||
const offset = item.innderOffset
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
const offset = item.offset ?? []
|
||
// #endif
|
||
|
||
|
||
return {
|
||
badge: item.badge,
|
||
dot: item.dot ?? false,
|
||
disabled: item.disabled ?? false,
|
||
label: item.label,
|
||
offset: offset,
|
||
value: item.value,
|
||
node: {
|
||
badge: item.badge,
|
||
dot: item.dot ?? false,
|
||
disabled: item.disabled ?? false,
|
||
label: item.label,
|
||
offset: offset,
|
||
value: item.value,
|
||
}
|
||
} as TabPanel
|
||
})
|
||
})
|
||
|
||
const currentIndex = computed(() : number => {
|
||
const index = tabs.value.findIndex((child, index) : boolean => (child.value ?? index) == currentValue.value)
|
||
return index > -1 ? index : 0;
|
||
})
|
||
|
||
|
||
const scrollRef = ref<UniScrollViewElement | null>(null)
|
||
const trackRef = ref<UniElement | null>(null)
|
||
const navRef = ref<UniElement | null>(null)
|
||
const innerRef = ref<UniElement | null>(null)
|
||
|
||
const tabRects = ref<DOMRect[]>([]) // 存储每个tab的尺寸位置
|
||
const containerWidth = ref(0) // 容器宽度
|
||
const trackLineWidth = ref(0)
|
||
const isInteracting = ref(false) // 是否正在交互
|
||
let timer = 0
|
||
|
||
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 measureTabs = () : Promise<void> => {
|
||
return new Promise((resolve) => {
|
||
// nextTick(() => {
|
||
if(tabRects.value.length > 0 && tabRects.value[0].width > 0) {
|
||
resolve()
|
||
}
|
||
// #ifdef APP || WEB
|
||
if (scrollRef.value == null) { // navRef.value == null ||
|
||
resolve()
|
||
return
|
||
};
|
||
// #ifdef WEB
|
||
const elements = Array.from(navRef.value!.children)
|
||
// #endif
|
||
// #ifndef WEB
|
||
const elements = scrollRef.value!.children;
|
||
// #endif
|
||
|
||
const tabsRects:DOMRect[] = []
|
||
elements.forEach((el)=>{
|
||
if(el.tagName == 'VIEW') {
|
||
tabsRects.push(el.getBoundingClientRect())
|
||
}
|
||
})
|
||
tabRects.value = tabsRects
|
||
containerWidth.value = scrollRef.value?.offsetWidth ?? 0
|
||
trackLineWidth.value = trackRef.value?.offsetWidth ?? 0
|
||
resolve()
|
||
// #endif
|
||
|
||
|
||
// #ifndef APP || WEB
|
||
Promise.all([
|
||
getRect(`.l-tabs__scroll`, context),
|
||
getRect(`.l-tabs__track`, context),
|
||
getAllRect(`.l-tabs__item`, context)
|
||
]).then(([scrollRect, trackRect, tabsRects]) => {
|
||
containerWidth.value = scrollRect.width
|
||
tabRects.value = tabsRects
|
||
trackLineWidth.value = trackRect.width
|
||
resolve()
|
||
})
|
||
// #endif
|
||
|
||
})
|
||
// })
|
||
|
||
}
|
||
|
||
const moveToActiveTab = () => {
|
||
measureTabs().then(() => {
|
||
const index = currentIndex.value
|
||
if (tabRects.value.length <= index) return
|
||
const tabRect = tabRects.value[index]
|
||
|
||
let offset = (tabRect.width - trackLineWidth.value) / 2
|
||
let [distance] = calculateTabPositions(tabRects.value, index)
|
||
if (totalTabsWidth.value == 0) return
|
||
|
||
// #ifdef APP || WEB
|
||
trackRef.value?.style.setProperty('transform', `translateX(${distance + offset}px)`)
|
||
// #endif
|
||
// #ifndef APP || WEB
|
||
trackDistance.value = distance + offset
|
||
// #endif
|
||
|
||
|
||
// 计算scroll-view需要滚动的距离
|
||
const scrollOffset = calcScrollOffset(
|
||
containerWidth.value,
|
||
tabRect.left,
|
||
tabRect.width,
|
||
lastLeft.value
|
||
)
|
||
|
||
const maxOffset = totalTabsWidth.value - containerWidth.value;
|
||
|
||
scrollLeft.value = clamp(scrollOffset, 0, maxOffset)
|
||
// isInteracting.value = false
|
||
})
|
||
}
|
||
const updateInnerStyle = (offset : number) => {
|
||
nextTick(() => {
|
||
// #ifdef WEB || APP
|
||
if (innerRef.value == null) return;
|
||
const width = innerRef.value!.parentElement?.offsetWidth ?? 0;
|
||
innerRef.value!.style.setProperty('width', `${tabs.value.length * width}px`)
|
||
innerRef.value!.style.setProperty('opacity', `1`)
|
||
|
||
const left = -width * currentIndex.value + offset;
|
||
if (offset != 0) {
|
||
innerRef.value!.style.setProperty('transition-duration', `0s`)
|
||
innerRef.value!.style.setProperty('transform', `translateX(${left}px)`)
|
||
} else {
|
||
if (props.animated) {
|
||
innerRef.value!.style.setProperty('transition-duration', offset != 0 || !props.animated ? '0s' : `${props.duration}s`)
|
||
}
|
||
nextTick(() => {
|
||
innerRef.value!.style.setProperty('transform', `translateX(${left}px)`)
|
||
})
|
||
}
|
||
// #endif
|
||
// #ifndef WEB || APP
|
||
// const width = innerRef.value!.parentElement?.offsetWidth ?? 0;
|
||
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)`
|
||
}
|
||
}).catch(err => {
|
||
|
||
})
|
||
// #endif
|
||
|
||
})
|
||
}
|
||
const onScroll = (e : UniScrollEvent) => {
|
||
lastLeft.value = e.detail.scrollLeft;
|
||
}
|
||
const updateDuration = (duration: number) => {
|
||
// #ifdef APP || WEB
|
||
trackRef.value?.style.setProperty('transition-duration', `${duration}s`)
|
||
// #endif
|
||
// #ifndef APP || WEB
|
||
innerStyle['transition-duration'] = `${duration}s`
|
||
// #endif
|
||
}
|
||
const onClick = (index : number, item : TabPanel) => {
|
||
isInteracting.value = true
|
||
const { value = index, disabled, label } = item;
|
||
if (disabled || currentValue.value == value) return
|
||
currentValue.value = value;
|
||
emits('click', value)
|
||
updateDuration(0.3)
|
||
nextTick(()=> {
|
||
moveToActiveTab()
|
||
})
|
||
|
||
clearTimeout(timer)
|
||
timer = setTimeout(()=>{
|
||
isInteracting.value = false
|
||
},500)
|
||
|
||
}
|
||
|
||
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) => {
|
||
isInteracting.value = true
|
||
if (!props.swipeable) return;
|
||
touch.start(event);
|
||
updateDuration(0.3)
|
||
}
|
||
|
||
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 = () => {
|
||
isInteracting.value = false
|
||
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)
|
||
}
|
||
|
||
const stopWatch = watch(tabs, (_v : TabPanel[]) => {
|
||
// 鸿蒙增加项时渲染比较慢 需要延时一下
|
||
setTimeout(() => {
|
||
moveToActiveTab()
|
||
}, 50)
|
||
|
||
})
|
||
|
||
const stopValueWatch = watch(currentValue, (_v : number) => {
|
||
|
||
if (tabs.value.length == 0) return
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
|
||
|
||
})
|
||
const stopVisibleWatch = watch(() : boolean => props.visible, (v : boolean) => {
|
||
if (!v) return
|
||
setTimeout(() => {
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
}, 100)
|
||
})
|
||
|
||
const updateTrackPosition = (progress: number)=>{
|
||
if (!props.syncSwiper || !props.showLine || progress == 0 || isInteracting.value) return
|
||
updateDuration(0)
|
||
const currentIdx = currentIndex.value
|
||
const next = ()=> {
|
||
|
||
const direction = progress > 0 ? 1 : -1
|
||
const nextIdx = currentIdx + direction
|
||
|
||
// 边界检查
|
||
if (nextIdx < 0 || nextIdx >= tabRects.value.length ) return
|
||
|
||
// 计算插值比例 (0~1)
|
||
const ratio = Math.abs(progress)
|
||
let currentOffset = (tabRects.value[currentIdx].width - trackLineWidth.value) / 2
|
||
let nextOffset = (tabRects.value[nextIdx].width - trackLineWidth.value) / 2
|
||
let [currentPos, nextPos] = calculateTabPositions(tabRects.value, currentIdx, nextIdx)
|
||
currentPos = currentPos + currentOffset
|
||
nextPos = nextPos + nextOffset
|
||
// 根据滚动方向计算最终位置
|
||
const newPosition = direction > 0
|
||
? currentPos + (nextPos - currentPos) * ratio
|
||
: currentPos - (currentPos - nextPos) * ratio
|
||
|
||
// #ifdef APP || WEB
|
||
nextTick(()=>{
|
||
requestAnimationFrame(()=>{
|
||
trackRef.value?.style.setProperty('transform', `translateX(${newPosition}px)`)
|
||
})
|
||
})
|
||
// #endif
|
||
// #ifndef APP || WEB
|
||
trackDistance.value = newPosition
|
||
// #endif
|
||
}
|
||
// requestAnimationFrame(()=>{
|
||
measureTabs().then(next)
|
||
// })
|
||
|
||
|
||
}
|
||
// 监听swiper进度变化
|
||
watch(():number => props.swiperProgress, (progress: number) => {
|
||
updateTrackPosition(progress)
|
||
})
|
||
|
||
|
||
// 添加监听器,当总宽度变化时emit事件
|
||
watch(totalTabsWidth, (newWidth: number, oldWidth: number) => {
|
||
if (newWidth != oldWidth && newWidth > 0) {
|
||
emits('total-width', newWidth)
|
||
}
|
||
})
|
||
onMounted(() => {
|
||
// 鸿蒙时间上不确定
|
||
nextTick(() => {
|
||
setTimeout(() => {
|
||
if (tabs.value.length == 0) return
|
||
moveToActiveTab()
|
||
updateInnerStyle(0)
|
||
}, 100)
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopWatch()
|
||
stopValueWatch()
|
||
stopVisibleWatch()
|
||
})
|
||
|
||
provide('LimeTabs', children)
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
@import './index';
|
||
</style> |