tab 选择栏的封装

实现的效果

企业微信截图_17682843542751.png
企业微信截图_17682843744380.png

1 常用的参数配置 props.js

export default {
  value: {
    type: Number,
    default: 0
  },
  modelValue: {
    type: Number,
    default: 0
  },
  tabs: {
    type: Array,
    default() {
      return []
    }
  },
  bgColor: {
    type: String,
    default: '#fff'
  },
  padding: {
    type: String,
    default: '0'
  },
  color: {
    type: String,
    default: '#333'
  },
  activeColor: {
    type: String,
    default: '#2979ff'
  },
  fontSize: {
    type: String,
    default: '28rpx'
  },
  activeFontSize: {
    type: String,
    default: '32rpx'
  },
  bold: {
    type: Boolean,
    default: false
  },
  scroll: {
    type: Boolean,
    default: true
  },
  height: {
    type: String,
    default: '70rpx'
  },
  lineColor: {
    type: String,
    default: '#2979ff'
  },
  lineHeight: {
    type: [String, Number],
    default: '10rpx'
  },
  lineScale: {
    type: Number,
    default: 0.5
  },
  lineRadius: {
    type: String,
    default: '10rpx'
  },
  pills: {
    type: Boolean,
    default: false
  },
  pillsColor: {
    type: String,
    default: '#2979ff'
  },
  pillsBorderRadius: {
    type: String,
    default: '10rpx'
  },
  field: {
    type: String,
    default: ''
  },
  fixed: {
    type: Boolean,
    default: false
  },
  paddingItem: {
    type: String,
    default: '0 22rpx'
  },
  lineAnimation: {
    type: Boolean,
    default: true
  },
  zIndex: {
    type: Number,
    default: 1993
  }
}


2. utils.js 防抖节流的方法

/**
 * 函数节流器。
 * 通过限制函数调用的频率,防止在高频率事件(如窗口滚动或鼠标移动)中过多调用给定的函数,从而优化性能。
 *
 * @param {Function} fn 要节流的函数。
 * @param {number} delay 延迟的毫秒数,在这段时间内只能调用一次给定的函数。
 * @returns {Function} 返回一个新的节流函数,它将控制原始函数的调用频率。
 */
export function throttle(fn, delay) {
  // 用于存储定时器ID
  let timeoutId
  // 用于记录上一次函数执行的时间
  let lastExecuted = 0

  // 返回一个节流函数
  return function () {
    // 保存当前上下文和参数
    const context = this
    const args = arguments
    // 获取当前时间
    const now = Date.now()
    // 计算剩余时间
    const remaining = delay - (now - lastExecuted)

    // 实际执行函数的内部函数
    function execute() {
      lastExecuted = now
      // 在当前上下文中调用原始函数,并传入参数
      fn.apply(context, args)
    }

    // 如果剩余时间小于等于0,表示可以执行函数
    if (remaining <= 0) {
      // 如果存在定时器,则清除定时器
      if (timeoutId) {
        clearTimeout(timeoutId)
        timeoutId = null
      }
      // 执行函数
      execute()
    } else {
      // 如果不存在定时器,则设置定时器
      if (!timeoutId) {
        timeoutId = setTimeout(() => {
          timeoutId = null
          // 执行函数
          execute()
        }, remaining)
      }
    }
  }
}

/**
 * 函数防抖动封装。
 * 函数防抖(debounce)是指在事件被触发n秒后,才执行回调,如果在这n秒内事件又被触发,则重新计时。
 * 主要用于限制函数调用的频率,常用于输入事件处理函数(如输入框的keyup事件)和窗口大小调整事件等。
 *
 * @param {Function} fn 需要被延迟执行的函数。
 * @param {number} delay 延迟执行的时间,单位为毫秒。
 * @returns {Function} 返回一个经过防抖动处理的函数。
 */
export function debounce(fn, delay) {
  // 用于存储定时器的变量
  let timer = null

  // 返回一个封装函数
  return function () {
    // 如果定时器存在,则清除之前的定时器
    if (timer) clearTimeout(timer)

    // 设置新的定时器,延迟执行原函数
    timer = setTimeout(() => {
      // 使用apply确保函数在正确的上下文中执行,并传递所有参数
      fn.apply(this, arguments)
    }, delay)
  }
}


3. v-tabs.vue 具体的页面及逻辑 兼容横竖屏幕 的

<template>
    <view class="v-tabs">
        <scroll-view
            :id="getDomId"
            :scroll-x="scroll"
            :scroll-left="scroll ? scrollLeft : 0"
            :scroll-with-animation="scroll"
            :style="{ position: fixed ? 'fixed' : 'relative', zIndex, width: '100%' }"
        >
            <view
                class="v-tabs__container"
                :style="{
                    display: scroll ? 'inline-flex' : 'flex',
                    whiteSpace: scroll ? 'nowrap' : 'normal',
                    background: bgColor,
                    height,
                    padding
                }"
            >
                <!-- Tab项:强制平分宽度 + 居中 -->
                <view
                    :class="['v-tabs__container-item', { disabled: !!v.disabled }, { active: current == i }]"
                    v-for="(v, i) in tabs"
                    :key="i"
                    :style="{
                        color: current == i ? activeColor : color,
                        fontSize: fontSize,
                        fontWeight: bold && current == i ? 'bold' : 'normal',
                        justifyContent: 'center',
                        flex: 1,
                        padding: paddingItem
                    }"
                    @click="handleTabClick(i)"
                >
                    <slot :row="v" :index="i">{{ field ? v[field] : v }}</slot>
                </view>
                
                <!-- 下划线:横屏专用定位 -->
                <template v-if="!!tabs.length && !pills">
                    <view
                        class="v-tabs__container-line"
                        :class="{ animation: lineAnimation }"
                        :style="{
                            background: lineColor,
                            width: lineWidth + 'px',
                            height: lineHeight,
                            borderRadius: lineRadius,
                            left: lineLeft + 'px',
                            bottom: 0
                        }"
                    />
                </template>
                
                <!-- 胶囊样式:横屏专用定位 -->
                <template v-if="!!tabs.length && pills">
                    <view
                        class="v-tabs__container-pills"
                        :class="{ animation: lineAnimation }"
                        :style="{
                            background: pillsColor,
                            borderRadius: pillsBorderRadius,
                            width: pillsWidth + 'px',
                            height: pillsHeight,
                            left: pillsLeft + 'px',
                            top: '50%',
                            transform: 'translateY(-50%)'
                        }"
                    />
                </template>
            </view>
        </scroll-view>
        <!-- fixed 占位符 -->
        <view class="v-tabs__placeholder" :style="{ height: fixed ? height : '0', padding }"></view>
    </view>
</template>

<script>
import { throttle } from './utils'
import props from './props'

/**
 * v-tabs 横屏专用零bug版
 * 核心特性:
 * 1. 横屏初始化100%对齐
 * 2. 无需依赖DOM位置读取
 * 3. 自动适配横屏宽度变化
 * 4. 下划线/胶囊永不偏移
 */
export default {
  name: 'VTabs',
  props,
  // #ifdef VUE3
  emits: ['update:modelValue', 'change'],
  // #endif
  data() {
    return {
      current: 0,        // 当前选中下标
      scrollLeft: 0,     // 滚动位置
      lineLeft: 0,       // 下划线左侧偏移
      lineWidth: 0,      // 下划线宽度
      pillsLeft: 0,      // 胶囊左侧偏移
      pillsWidth: 0,     // 胶囊宽度
      pillsHeight: '80%',// 胶囊高度
      containerWidth: 0  // 容器宽度
    }
  },
  computed: {
    // 生成唯一ID(简化版,避免随机数导致的问题)
    getDomId() {
      return `v-tabs-${this._uid}`
    },
    // 单个Tab宽度(横屏核心)
    singleTabWidth() {
      return this.containerWidth / this.tabs.length
    }
  },
  watch: {
    // 监听选中值变化(兼容Vue2/Vue3)
    // #ifdef VUE3
    modelValue: {
      immediate: true,
      handler(newVal) {
        this.current = newVal > -1 && newVal < this.tabs.length ? newVal : 0
        this.updatePosition()
      }
    },
    // #endif
    // #ifdef VUE2
    value: {
      immediate: true,
      handler(newVal) {
        this.current = newVal > -1 && newVal < this.tabs.length ? newVal : 0
        this.updatePosition()
      }
    },
    // #endif
    // 监听tabs变化,重新计算位置
    tabs: {
      immediate: true,
      handler() {
        this.initContainerWidth()
      }
    }
  },
  mounted() {
    // 初始化容器宽度(横屏专用)
    this.initContainerWidth()
    
    // 监听屏幕旋转(横屏核心)
    // #ifdef APP-PLUS
    plus.screen.addEventListener('orientationchange', () => {
      setTimeout(() => {
        this.initContainerWidth()
      }, 200)
    })
    // #endif
  },
  methods: {
    // 初始化容器宽度(横屏稳定后读取)
    initContainerWidth() {
      // 延迟执行,确保横屏DOM稳定
      setTimeout(() => {
        const query = uni.createSelectorQuery().in(this)
        query.select(`#${this.getDomId}`).boundingClientRect(rect => {
          if (rect && rect.width) {
            this.containerWidth = rect.width
            this.updatePosition()
          }
        }).exec()
      }, 300)
    },
    // Tab点击事件(防抖+禁用判断)
    handleTabClick: throttle(function(index) {
      const isDisabled = !!this.tabs[index]?.disabled
      if (this.current !== index && !isDisabled) {
        this.current = index
        // 触发事件(兼容Vue2/Vue3)
        // #ifdef VUE3
        this.$emit('update:modelValue', index)
        // #endif
        // #ifdef VUE2
        this.$emit('input', index)
        // #endif
        this.$emit('change', index)
        this.updatePosition()
      }
    }, 300),
    // 更新下划线/胶囊位置(横屏核心算法)
    updatePosition() {
      if (!this.containerWidth || !this.tabs.length) return
      
      // 计算单个Tab宽度
      const tabWidth = this.singleTabWidth
      // 计算当前Tab起始位置
      const tabStart = this.current * tabWidth
      
      // 下划线配置(横屏精准对齐)
      this.lineWidth = tabWidth * (this.lineScale || 0.8)  // 下划线宽度
      this.lineLeft = tabStart + (tabWidth - this.lineWidth) / 2  // 下划线居中
      
      // 胶囊配置
      this.pillsWidth = tabWidth * 0.9
      this.pillsLeft = tabStart + (tabWidth - this.pillsWidth) / 2
    },
    // 暴露给父组件的手动更新方法
    update() {
      this.initContainerWidth()
    }
  }
}
</script>

<style lang="scss" scoped>
.v-tabs {
    width: 100%;
    box-sizing: border-box;
    overflow: hidden;

    /* 隐藏滚动条 */
    /* #ifdef H5 */
    ::-webkit-scrollbar {
        display: none;
    }
    /* #endif */

    &__container {
        min-width: 100%;
        position: relative;
        display: flex;
        align-items: center;
        overflow: hidden;

        &-item {
            flex: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            position: relative;
            z-index: 10;
            transition: color 0.3s ease;
            white-space: nowrap;

            &.disabled {
                opacity: 0.5;
                color: #999;
                pointer-events: none;
            }
        }

        &-line {
            position: absolute;
            transition: left 0.3s ease;
            transform: translateZ(0); // 消除像素偏差
        }

        &-pills {
            position: absolute;
            transition: left 0.3s ease;
            transform: translateZ(0); // 消除像素偏差
            z-index: 9;
        }

        &-line.animation,
        &-pills.animation {
            transition: all 0.3s linear;
        }
    }

    &__placeholder {
        box-sizing: border-box;
    }
}
</style>

4. 使用说明

<template>
  <view>
    <!-- 横屏平分模式(推荐) -->
    <v-tabs
      ref="tabsRef"
      v-model="currentTab"
      :tabs="tabsList"
      :scroll="false"  <!-- 横屏强制平分宽度 -->
      :line-scale="0.8" <!-- 下划线宽度占Tab的80% -->
      lineColor="#007aff"
      activeColor="#007aff"
      color="#666"
      height="70rpx"
      fontSize="28rpx"
      @change="handleTabChange"
    ></v-tabs>

    <!-- 竖屏滚动模式(Tab数量多时) -->
    <v-tabs
      v-model="currentTab"
      :tabs="longTabsList"
      :scroll="true"  <!-- 开启滚动 -->
      paddingItem="0 22rpx"  <!-- 单个Tab左右内边距 -->
      :pills="true"  <!-- 胶囊样式 -->
      pillsColor="#f56c6c"
    ></v-tabs>
  </view>
</template>

<script setup>
import { ref, onShow } from 'vue'
import VTabs from '@/components/v-tabs/v-tabs.vue'

const tabsRef = ref(null)
const currentTab = ref(0)
// 基础Tab列表(字符串数组)
const tabsList = ['超载数量', '牲畜数量', '流转面积', '奖补面积']
// 长Tab列表(对象数组,配合field使用)
const longTabsList = [
  { id: 1, name: '首页' },
  { id: 2, name: '数据统计' },
  { id: 3, name: '报表分析' },
  { id: 4, name: '系统设置' },
  { id: 5, name: '帮助中心' }
]

// Tab切换事件
const handleTabChange = (index) => {
  console.log('选中第', index + 1, '个Tab')
}

// 可选:横屏后手动触发更新(双重保障)
onShow(() => {
  setTimeout(() => {
    tabsRef.value?.update()
  }, 500)
})
</script>

5 参数说明

value   Number  0   Vue2 双向绑定值(选中 Tab 的下标)
modelValue  Number  0   Vue3 双向绑定值(选中 Tab 的下标)
tabs    Array   []  Tab 列表数据:
1. 字符串数组:['标题1', '标题2']
2. 对象数组:需配合field使用
bgColor String  '#fff'  Tab 栏背景色
padding String  '0' Tab 容器整体内边距(如 10rpx 0)
color   String  '#333'  未选中 Tab 的文字颜色
activeColor String  '#2979ff'   选中 Tab 的文字颜色
fontSize    String  '28rpx' 默认文字大小
activeFontSize  String  '32rpx' 选中 Tab 的文字大小(优先级高于 fontSize)
bold    Boolean false   选中 Tab 的文字是否加粗
scroll  Boolean true    是否开启横向滚动:
true - 滚动模式(Tab 数量多)
false - 平分模式(横屏推荐)
height  String  '70rpx' Tab 栏整体高度(如 80rpx)
lineColor   String  '#2979ff'   下划线颜色(非胶囊模式生效)
lineHeight  String/Number   '10rpx' 下划线高度(如 8rpx)
lineScale   Number  0.5 下划线宽度比例(相对于单个 Tab 宽度,0-1 之间,如 0.8 = 占 80%)
lineRadius  String  '10rpx' 下划线圆角(如 5rpx)
pills   Boolean false   是否开启胶囊样式:
true - 胶囊背景(覆盖下划线)
false - 下划线样式
pillsColor  String  '#2979ff'   胶囊背景色(胶囊模式生效)
pillsBorderRadius   String  '10rpx' 胶囊圆角(如 20rpx,推荐设为高度的一半实现圆形)
field   String  ''  对象数组时指定显示字段(如 tabs=[{name:'标题'}],field='name')
fixed   Boolean false   是否固定在顶部:
true - 固定(自动生成占位符)
false - 相对定位
paddingItem String  '0 22rpx'   单个 Tab 的内边距(滚动模式下控制左右间距)
lineAnimation   Boolean true    是否开启下划线 / 胶囊切换动画
zIndex  Number  1993    Tab 栏层级(fixed=true 时建议提高)

事件说明

change  Tab 被点击且切换成功时   index: Number   返回选中 Tab 的下标(从 0 开始)
input   Vue2 双向绑定触发 index: Number   同 change,适配 Vue2 v-model
update:modelValue   Vue3 双向绑定触发 index: Number   同 change,适配 Vue3 v-model

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容