Files
qiming/qiming-mobile/uni_modules/lime-tabs/components/l-tabs/l-tabs.uvue

612 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>