| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- <template>
- <view
- class="cl-tabs"
- :class="[
- {
- 'cl-tabs--line': showLine,
- 'cl-tabs--slider': showSlider,
- 'cl-tabs--fill': fill,
- 'cl-tabs--disabled': disabled,
- 'is-dark': isDark
- },
- pt.className
- ]"
- :style="{
- height: parseRpx(height!)
- }"
- >
- <scroll-view
- class="cl-tabs__scrollbar"
- :scroll-with-animation="true"
- :scroll-x="true"
- direction="horizontal"
- :scroll-left="scrollLeft"
- :show-scrollbar="false"
- >
- <view class="cl-tabs__inner">
- <view
- class="cl-tabs__item"
- v-for="(item, index) in list"
- :key="index"
- :class="[pt.item?.className]"
- :style="{
- padding: `0 ${parseRpx(gutter)}`
- }"
- @tap="change(index)"
- >
- <slot name="item" :item="item" :active="item.isActive">
- <cl-text
- :pt="{
- className: parseClass([
- [
- item.isActive && color == '' && unColor == '',
- showSlider ? 'text-white' : 'text-primary-500'
- ],
- pt.text?.className
- ])
- }"
- :style="getTextStyle(item)"
- >{{ item.label }}</cl-text
- >
- </slot>
- </view>
- <template v-if="lineLeft > 0">
- <view
- class="cl-tabs__line"
- :class="[pt.line?.className]"
- :style="lineStyle"
- v-if="showLine"
- ></view>
- <view
- class="cl-tabs__slider"
- :class="[pt.slider?.className]"
- :style="sliderStyle"
- v-if="showSlider"
- ></view>
- </template>
- </view>
- </scroll-view>
- </view>
- </template>
- <script lang="ts" setup>
- import { type PropType, computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
- import { isDark, isEmpty, isHarmony, isNull, parseClass, parsePt, parseRpx, rpx2px } from "@/cool";
- import type { ClTabsItem, PassThroughProps } from "../../types";
- // 定义标签类型
- type Item = {
- label: string;
- value: string | number;
- disabled: boolean;
- isActive: boolean;
- };
- defineOptions({
- name: "cl-tabs"
- });
- defineSlots<{
- item(props: { item: Item; active: boolean }): any;
- }>();
- const props = defineProps({
- // 透传属性对象,允许外部自定义样式和属性
- pt: {
- type: Object,
- default: () => ({})
- },
- // v-model绑定值,表示当前选中的tab
- modelValue: {
- type: [String, Number] as PropType<string | number>,
- default: ""
- },
- // 标签高度
- height: {
- type: [String, Number] as PropType<string | number>,
- default: 80
- },
- // 标签列表
- list: {
- type: Array as PropType<ClTabsItem[]>,
- default: () => []
- },
- // 是否填充标签
- fill: {
- type: Boolean,
- default: false
- },
- // 标签间隔
- gutter: {
- type: Number,
- default: 30
- },
- // 选中标签的颜色
- color: {
- type: String,
- default: ""
- },
- // 未选中标签的颜色
- unColor: {
- type: String,
- default: ""
- },
- // 是否显示下划线
- showLine: {
- type: Boolean,
- default: true
- },
- // 是否显示滑块
- showSlider: {
- type: Boolean,
- default: false
- },
- // 是否禁用
- disabled: {
- type: Boolean,
- default: false
- }
- });
- // 定义事件发射器
- const emit = defineEmits(["update:modelValue", "change"]);
- // 获取当前组件实例的proxy对象
- const { proxy } = getCurrentInstance()!;
- // 定义透传类型,便于类型推断和扩展
- type PassThrough = {
- // 额外类名
- className?: string;
- // 文本的透传属性
- text?: PassThroughProps;
- // 单个item的透传属性
- item?: PassThroughProps;
- // 下划线的透传属性
- line?: PassThroughProps;
- // 滑块的透传属性
- slider?: PassThroughProps;
- };
- // 计算透传属性,便于样式和属性扩展
- const pt = computed(() => parsePt<PassThrough>(props.pt));
- // 当前选中的标签值
- const active = ref(props.modelValue);
- // 计算标签列表,增加isActive和disabled属性,便于渲染和状态判断
- const list = computed(() =>
- props.list.map((e) => {
- return {
- label: e.label,
- value: e.value,
- // 如果未传disabled则默认为false
- disabled: e.disabled ?? false,
- // 判断当前标签是否为激活状态
- isActive: e.value == active.value
- } as Item;
- })
- );
- // 切换标签时触发,参数为索引
- async function change(index: number) {
- // 如果整个Tabs被禁用,则不响应点击
- if (props.disabled) {
- return false;
- }
- // 获取当前点击标签的值
- const { value, disabled } = list.value[index];
- // 如果标签被禁用,则不响应点击
- if (disabled) {
- return false;
- }
- // 触发v-model的更新
- emit("update:modelValue", value);
- // 触发change事件
- emit("change", value);
- }
- // 获取当前选中标签的下标,未找到则返回0
- function getIndex() {
- const index = list.value.findIndex((e) => e.isActive);
- return index == -1 ? 0 : index;
- }
- // 根据激活状态获取标签颜色
- function getColor(isActive: boolean) {
- let color: string;
- // 选中时取props.color,否则取props.unColor
- if (isActive) {
- color = props.color;
- } else {
- color = props.unColor;
- }
- return isEmpty(color) ? null : color;
- }
- // tab区域宽度
- const tabWidth = ref(0);
- // tab区域左侧偏移
- const tabLeft = ref(0);
- // 下划线左侧偏移
- const lineLeft = ref(0);
- // 滑块左侧偏移
- const sliderLeft = ref(0);
- // 滑块宽度
- const sliderWidth = ref(0);
- // 滚动条左侧偏移
- const scrollLeft = ref(0);
- // 单个标签的位置信息类型,包含left和width
- type ItemRect = {
- left: number;
- width: number;
- };
- // 所有标签的位置信息,响应式数组
- const itemRects = ref<ItemRect[]>([]);
- // 计算下划线样式
- const lineStyle = computed(() => {
- const style = {};
- style["transform"] = `translateX(${lineLeft.value}px)`;
- // 获取选中颜色
- const bgColor = getColor(true);
- if (bgColor != null) {
- style["backgroundColor"] = bgColor;
- }
- return style;
- });
- // 计算滑块样式
- const sliderStyle = computed(() => {
- const style = {};
- style["transform"] = `translateX(${sliderLeft.value}px)`;
- style["width"] = sliderWidth.value + "px";
- // 获取选中颜色
- const bgColor = getColor(true);
- if (bgColor != null) {
- style["backgroundColor"] = bgColor;
- }
- return style;
- });
- // 获取文本样式
- function getTextStyle(item: Item) {
- const style = {};
- // 获取选中颜色
- const color = getColor(item.isActive);
- if (color != null) {
- style["color"] = color;
- }
- return style;
- }
- // 更新下划线、滑块、滚动条等位置
- function updatePosition() {
- nextTick(() => {
- if (!isEmpty(itemRects.value)) {
- // 获取当前选中标签的位置信息
- const item = itemRects.value[getIndex()];
- // 如果标签存在
- if (!isNull(item)) {
- // 计算滚动条偏移,使选中项居中
- let x = item.left - (tabWidth.value - item.width) / 2 - tabLeft.value;
- // 防止滚动条偏移为负
- if (x < 0) {
- x = 0;
- }
- // 设置滚动条偏移
- scrollLeft.value = x;
- // 设置下划线偏移,使下划线居中于选中项
- lineLeft.value = item.left + item.width / 2 - rpx2px(16) - tabLeft.value;
- // 设置滑块左侧偏移
- sliderLeft.value = item.left - tabLeft.value;
- // 设置滑块宽度
- sliderWidth.value = item.width;
- }
- }
- });
- }
- // 获取所有标签的位置信息,便于后续计算
- function getRects() {
- // 创建选择器查询
- uni.createSelectorQuery()
- // 作用域限定为当前组件
- .in(proxy)
- // 选择所有标签元素
- .selectAll(".cl-tabs__item")
- // 获取rect和size信息
- .fields({ rect: true, size: true }, () => {})
- // 执行查询
- .exec((nodes) => {
- // 解析查询结果,生成ItemRect数组
- itemRects.value = (nodes[0] as NodeInfo[]).map((e) => {
- return {
- left: e.left ?? 0,
- width: e.width ?? 0
- } as ItemRect;
- });
- // 更新下划线、滑块等位置
- updatePosition();
- });
- }
- // 刷新tab区域的宽度和位置信息
- function refresh() {
- setTimeout(
- () => {
- // 创建选择器查询
- uni.createSelectorQuery()
- // 作用域限定为当前组件
- .in(proxy)
- // 选择tab容器
- .select(".cl-tabs")
- // 获取容器的left和width
- .boundingClientRect((node) => {
- // 设置tab左侧偏移
- tabLeft.value = (node as NodeInfo).left ?? 0;
- // 设置tab宽度
- tabWidth.value = (node as NodeInfo).width ?? 0;
- // 获取所有标签的位置信息
- getRects();
- })
- .exec();
- },
- isHarmony() ? 50 : 0
- );
- }
- // 监听modelValue变化,更新active和位置
- watch(
- computed(() => props.modelValue!),
- (val: string | number) => {
- // 更新当前选中标签
- active.value = val;
- // 更新下划线、滑块等位置
- updatePosition();
- },
- {
- // 立即执行一次
- immediate: true
- }
- );
- // 监听标签列表变化,刷新布局
- watch(
- computed(() => props.list),
- () => {
- nextTick(() => {
- refresh();
- });
- }
- );
- // 组件挂载时刷新布局,确保初始渲染正确
- onMounted(() => {
- refresh();
- });
- </script>
- <style lang="scss" scoped>
- .cl-tabs {
- &__scrollbar {
- @apply flex flex-row w-full h-full;
- }
- &__inner {
- @apply flex flex-row relative;
- }
- &__item {
- @apply flex flex-row items-center justify-center h-full relative z-10;
- }
- &__line {
- @apply bg-primary-500 rounded-md absolute;
- height: 4rpx;
- width: 16px;
- bottom: 2rpx;
- left: 0;
- transition-property: transform;
- transition-duration: 0.3s;
- }
- &__slider {
- @apply bg-primary-500 rounded-lg absolute h-full w-full;
- top: 0;
- left: 0;
- transition-property: transform;
- transition-duration: 0.3s;
- }
- &--slider {
- @apply bg-surface-50 rounded-lg;
- &.is-dark {
- @apply bg-surface-700;
- }
- }
- &--fill {
- .cl-tabs__inner {
- @apply w-full;
- }
- .cl-tabs__item {
- flex: 1;
- }
- .cl-tabs__item-label {
- @apply text-center;
- }
- }
- &--disabled {
- @apply opacity-50;
- }
- }
- </style>
|