uniapp 实现tab标签页与内容的联动滑动

897 阅读2分钟

背景

项目需要实现类似 Vant 组件库的 Tab标签页 组件,标签能够与内容区域实现联动效果,但是项目使用的 uView 组件库没有此组件,只有Tabs标签 组件,不能与内容区域进行联动,所以手写了一个demo

实现效果

20241209_112102.gif

思路

标签区域

图解:

image.png

思考一下:想要实现 选中标签 居中效果,就需要判断 选中标签 是否需要居中?如果需要,那么 偏移多少才能居中?

首先需要得到所有标签元素的 宽高 以及 位置信息

const query = uni.createSelectorQuery().in(this);

this.tabs.forEach((_, index) => {
    query.select(`#tab${index}`).boundingClientRect();
});

query.exec((rects) => {
    this.tabRects = rects;
});

当点击目标元素,使其居中

const rect = this.tabRects[index];

// 得到目标元素中心点距离左边的距离
const tabCenter = rect.left + rect.width / 2;

// 计算目标元素居中的偏移量
this.scrollLeft = tabCenter - this.windowWidth / 2;
// 这里得到 scrollLeft 为选中标签中心点距离左侧的距离与包含块的中心点的差,判断边界问题

但是需要考虑边界的问题,看是否需要居中。所以需要改动一下

// 计算所有标签的总宽度
const totalWidth = this.tabRects.reduce((sum, tab) => sum + tab.width, 0);

// 计算最大的滚动距离,总宽度减去包含块宽度
const maxScroll = Math.max(0, totalWidth - this.windowWidth);

// 处理左右边界问题(前面几项以及最后几项实际上不需要居中的只需靠边即可)
this.scrollLeft = Math.max(0, Math.min(scrollLeft, maxScroll));

// Math.max(0, Math.min(scrollLeft, maxScroll)) 
// 怎么理解这一段代码?首先看这个 Math.min(scrollLeft, maxScroll) 表示选中标签距离左边的距离大于了最大滚动距离时,说明这时的选中标签是不需要居中的,已经在最右边了,不能再滚动了,所以就取最大滚动距离
// Math.max(0, Math.min(scrollLeft, maxScroll)) 
// 然后整体来看就是当 Math.min(scrollLeft, maxScroll) 为负数时说明已经在最左边了,不需要移动,取0即可

处理下滑线位置以及宽度

const rect = this.tabRects[index];

// 计算下划线的左边位置,加上左边距
this.lineLeft = rect.left + this.margin;

// 计算下划线的宽度,减去两边的边距(这里看你需要的下滑线是怎么样的,是否与文字宽度一样,还是需要加上边距的距离,示例是与文字对齐)
this.lineWidth = rect.width - this.margin * 2;

内容区域

思考一下:怎么判断滑动多少才会进行切换

触摸开始事件

// 记录开始触摸时的触摸位置
this.touchStartX = e.touches[0].clientX

// 保存开始触摸时的偏移量
this.touchStartOffset = this.contentOffset

触摸移动事件

// 计算滑动的距离(目前触摸的位置减去开始触摸时记录的触摸位置)
this.deltaX = e.touches[0].clientX - this.touchStartX
// 得到新的偏移量
this.contentOffset = this.touchStartOffset + this.daltaX

触摸结束事件

// 得到滑动的距离
const deltaX = this.contentOffset - this.touchStartOffset
// 得到需要滑动多少距离才能切换的阀值 (这里 this.switchThreshold 为 0.2,表示需要滑动屏幕五分之一的距离才行)
const threshold = this.windowWidth * this.switchThreshold

let targetIndex = this.currentIndex;

// 判断是否达到切换阈值
if (Math.abs(deltaX) >= threshold) {
    // 如果向右滑动且当前标签页不是第一个,则切换到前一个标签页
    if (deltaX > 0 && this.currentIndex > 0) {
        targetIndex = this.currentIndex - 1;
    }
    // 如果向左滑动且当前标签页不是最后一个,则切换到后一个标签页
    else if (deltaX < 0 && this.currentIndex < this.tabs.length - 1) {
        targetIndex = this.currentIndex + 1;
    }
}

还有最后一个边界问题需要处理,修改一下触摸移动事件

// 计算滑动的距离(目前触摸的位置减去开始触摸时记录的触摸位置)
this.deltaX = e.touches[0].clientX - this.touchStartX
// 得到新的偏移量
const newOffset = this.touchStartOffset + this.daltaX

// 得到滑动的限制范围
const maxOffset = 0;
const minOffset = -(this.tabs.length - 1) * this.windowWidth;

// 如果超出滑动范围则直接返回
if (newOffset > maxOffset || newOffset < minOffset) return;

// 更新内容的偏移量
this.contentOffset = newOffset;


// 如果需要选中标签的下滑线也跟着移动就加上
// 计算进度并更新下划线
const progress = -this.contentOffset / this.windowWidth;
const leftIndex = Math.floor(progress);
const rightIndex = Math.ceil(progress);

// 确保索引在有效范围内
if (leftIndex >= 0 && rightIndex < this.tabs.length) {
    const leftRect = this.tabRects[leftIndex];
    const rightRect = this.tabRects[rightIndex];
    const percent = progress - leftIndex;

    // 计算下划线的左边位置
    this.lineLeft = leftRect.left + this.margin + (rightRect.left - leftRect.left) * percent;
    // 计算下划线的宽度
    this.lineWidth = leftRect.width - this.margin * 2 + (rightRect.width - leftRect.width) * percent;
}

实现

父组件

<template>
  <MyTabs :tabs="tabs" @change="changeFn" @scrolltolower="scrolltolower">
    <template :slot="`tab-${index}`" v-for="(item, index) in tabs">
      <view class="content" :key="index">内容{{ index }}</view>
    </template>
  </MyTabs>
</template>

<script>
import MyTabs from "@/components/MyTabs/MyTabs.vue";
export default {
  components: { MyTabs },
  data() {
    return {
      tabs: [
        {
          title: "标题0",
        },
        {
          title: "标题1",
        },
        {
          title: "标题2",
        },
        {
          title: "标题3",
        },
        {
          title: "标题4",
        },
        {
          title: "标题5",
        },
        {
          title: "标题6",
        },
        {
          title: "标题7",
        },
        {
          title: "标题8",
        },
      ],
    };
  },
  methods: {
    // 选中标题变化触发
    changeFn(index) {
      console.log("index", index);
    },
    // 上滑加载更多
    scrolltolower() {
      console.log("上滑加载");
    },
  },
};
</script>

<style lang="scss" scoped>
.content {
  text-align: center;
}
</style>

子组件

<template>
  <view class="tabs">
    <scroll-view
      class="tabs-nav"
      scroll-x
      scroll-with-animation
      :scroll-left="scrollLeft"
    >
      <view class="tabs-nav-wrap">
        <view
          v-for="(item, index) in tabs"
          :key="index"
          class="tab-item"
          :class="{ active: currentIndex === index }"
          :id="`tab${index}`"
          @tap="handleTabClick(index)"
        >
          {{ item.title }}
        </view>
        <view
          class="tab-line"
          :style="{
            transform: `translateX(${lineLeft}px)`,
            width: `${lineWidth}px`,
          }"
        />
      </view>
    </scroll-view>

    <view
      class="tabs-content"
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
    >
      <view
        class="tabs-content-wrap"
        :style="{
          transform: `translateX(${contentOffset}px)`,
          transition: `${contentTransition}`,
        }"
      >
        <scroll-view
          scroll-y
          v-for="(item, index) in tabs"
          :key="index"
          class="tab-pane"
          @scrolltolower="scrolltolower(index)"
        >
          <slot :name="'tab-' + index" />
        </scroll-view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: "my-tabs",
  props: {
    // tabs 数据
    tabs: {
      type: Array,
      default: () => [],
    },
    // 默认选中的索引
    defaultIndex: {
      type: Number,
      default: 0,
    },
  },
  data() {
    return {
      // 当前选中 tab 的索引
      currentIndex: this.defaultIndex,
      // 滚动容器的左偏移量
      scrollLeft: 0,
      // 存储每个 tab 的位置信息
      tabRects: [],
      // 窗口的宽度
      windowWidth: 0,
      // 指示器的左边距
      lineLeft: 0,
      // 指示器的宽度
      lineWidth: 0,
      // 内容的偏移量
      contentOffset: 0,
      // 内容的过渡动画
      contentTransition: "",
      // 触摸开始时的 X 坐标
      touchStartX: 0,
      // 触摸开始时的内容偏移量
      touchStartOffset: 0,
      // 是否正在触摸
      isTouching: false,
      // 切换阈值,屏幕的五分之一
      switchThreshold: 0.2,
      // tab之间的间距
      margin: 15,
    };
  },
  watch: {
    tabs: {
      handler() {
        this.$nextTick(() => {
          this.init();
        });
      },
      immediate: true,
    },
    currentIndex(newVal) {
      this.contentOffset = -newVal * this.windowWidth;
    },
  },
  mounted() {
    // 获取屏幕宽度
    const info = uni.getSystemInfoSync();
    this.windowWidth = info.windowWidth || 375;
    this.contentOffset = -this.currentIndex * this.windowWidth;
  },
  methods: {
    // 初始化
    async init() {
      if (this.tabs.length === 0) return;
      await this.updateTabRects();
      this.updateLine(this.currentIndex);
      this.updateScroll(this.currentIndex);
    },

    /**
     * 异步更新标签位置信息
     * 该方法用于测量和更新每个标签在界面上的位置和尺寸信息
     * 使用setTimeout和uni.createSelectorQuery().in(this)来确保在DOM完全渲染后进行测量
     * @returns {Promise} 返回一个Promise对象,表示标签位置信息更新完成
     */
    async updateTabRects() {
      return new Promise((resolve) => {
        setTimeout(() => {
          // 创建一个选择器查询对象,用于获取页面中的节点信息
          const query = uni.createSelectorQuery().in(this);

          // 遍历每个标签页,对其执行 boundingClientRect 方法以获取其在页面中的布局信息
          this.tabs.forEach((_, index) => {
            query.select(`#tab${index}`).boundingClientRect();
          });

          // 执行所有之前的 select 操作,获取节点信息
          query.exec((rects) => {
            // 如果没有获取到任何节点信息,则直接 resolve
            if (!rects || rects.length === 0) {
              resolve();
              return;
            }
            // 将获取到的节点信息保存到组件的 tabRects 属性中
            this.tabRects = rects;
            // 完成节点信息的保存,resolve 表示异步操作完成
            resolve();
          });
        }, 50);
      });
    },

    // 更新下划线位置和宽度
    updateLine(index) {
      // 获取指定索引处的标签位置信息
      const rect = this.tabRects[index];
      // 如果该索引处没有位置信息,则直接返回
      if (!rect) return;

      // 计算下划线的左边位置,加上左边距
      this.lineLeft = rect.left + this.margin;
      // 计算下划线的宽度,减去两边的边距
      this.lineWidth = rect.width - this.margin * 2;
    },

    // 更新tabs选中tab位置
    updateScroll(index) {
      // 获取指定索引的tab矩形信息
      const rect = this.tabRects[index];
      // 如果矩形信息不存在,则直接返回
      if (!rect) return;

      // 计算tab的中心点位置
      const tabCenter = rect.left + rect.width / 2;
      // 计算滚动到使tab位于窗口中心的滚动左位置
      const scrollLeft = tabCenter - this.windowWidth / 2;

      // 计算所有tab的总宽度
      const totalWidth = this.tabRects.reduce((sum, tab) => sum + tab.width, 0);
      // 计算最大的滚动距离
      const maxScroll = Math.max(
        0,
        totalWidth - this.windowWidth + this.margin * 2
      );

      // 更新滚动左位置,确保滚动位置在有效范围内
      this.scrollLeft = Math.max(0, Math.min(scrollLeft, maxScroll));
    },

    // 点击 tab
    handleTabClick(index) {
      if (this.currentIndex === index) return;
      this.switchTab(index);
    },

    /**
     * 处理触摸开始事件
     * @param {TouchEvent} e - 触摸事件对象
     *
     * 此函数在用户开始触摸屏幕时被触发它记录触摸的起始位置和当前内容的滚动位置
     * 以便在用户滑动屏幕时计算滑动距离和设置滚动位置同时,它还会设置一个标志
     * 表示当前正在触摸,以及重置内容的过渡效果
     */
    handleTouchStart(e) {
      // 记录触摸起始位置的X坐标
      this.touchStartX = e.touches[0].clientX;
      // 记录当前内容滚动的位置
      this.touchStartOffset = this.contentOffset;
      // 设置正在触摸的标志为true
      this.isTouching = true;
      // 重置内容的CSS过渡效果
      this.contentTransition = "";
    },

    /**
     * 处理触摸移动事件
     * @param {TouchEvent} e 触摸事件对象
     */
    handleTouchMove(e) {
      // 如果没有触摸动作则直接返回
      if (!this.isTouching) return;

      // 计算横向滑动距离
      const deltaX = e.touches[0].clientX - this.touchStartX;
      // 根据滑动距离计算新的偏移量
      const newOffset = this.touchStartOffset + deltaX;

      // 限制滑动范围
      const maxOffset = 0;
      const minOffset = -(this.tabs.length - 1) * this.windowWidth;

      // 如果超出滑动范围则直接返回
      if (newOffset > maxOffset || newOffset < minOffset) return;

      // 更新内容的偏移量
      this.contentOffset = newOffset;

      // 计算进度并更新下划线
      const progress = -newOffset / this.windowWidth;
      const leftIndex = Math.floor(progress);
      const rightIndex = Math.ceil(progress);

      // 确保索引在有效范围内
      if (leftIndex >= 0 && rightIndex < this.tabs.length) {
        const leftRect = this.tabRects[leftIndex];
        const rightRect = this.tabRects[rightIndex];
        const percent = progress - leftIndex;

        // 计算下划线的左边位置
        this.lineLeft =
          leftRect.left +
          this.margin +
          (rightRect.left - leftRect.left) * percent;
        // 计算下划线的宽度
        this.lineWidth =
          leftRect.width -
          this.margin * 2 +
          (rightRect.width - leftRect.width) * percent;
      }
    },

    /**
     * 处理触摸结束事件
     * @param {Event} e 触摸事件对象
     */
    handleTouchEnd(e) {
      // 如果没有触摸操作正在进行,则直接返回
      if (!this.isTouching) return;
      this.isTouching = false;

      // 计算触摸移动的距离
      const deltaX = this.contentOffset - this.touchStartOffset;
      // 计算切换阈值
      const threshold = this.windowWidth * this.switchThreshold;

      // 初始化目标标签页索引为当前标签页索引
      let targetIndex = this.currentIndex;

      // 判断是否达到切换阈值
      if (Math.abs(deltaX) >= threshold) {
        // 如果向右滑动且当前标签页不是第一个,则切换到前一个标签页
        if (deltaX > 0 && this.currentIndex > 0) {
          targetIndex = this.currentIndex - 1;
        }
        // 如果向左滑动且当前标签页不是最后一个,则切换到后一个标签页
        else if (deltaX < 0 && this.currentIndex < this.tabs.length - 1) {
          targetIndex = this.currentIndex + 1;
        }
      }

      // 切换到目标标签页
      this.switchTab(targetIndex);
    },

    switchTab(index) {
      this.contentTransition = "transform 0.3s";
      this.contentOffset = -index * this.windowWidth;
      this.updateLine(index);
      this.updateScroll(index);
      if (this.currentIndex === index) return;
      this.currentIndex = index;
      this.$emit("change", index);
    },
    scrolltolower(index) {
      this.$emit("scrolltolower", index);
    },
  },
};
</script>

<style lang="scss" scoped>
/deep/ ::-webkit-scrollbar {
  display: block;
  width: 0 !important;
  height: 0 !important;
  -webkit-appearance: auto !important;
  background: transparent;
  overflow: auto !important;
}
.tabs {
  --height: 88rpx;
  width: 100%;

  .tabs-nav {
    width: 100%;
    height: var(--height);
    background: #fff;
    position: relative;
    white-space: nowrap;

    .tabs-nav-wrap {
      display: flex;
      align-items: center;
      height: 100%;
      position: relative;
    }

    .tab-item {
      position: relative;
      padding: 0 30rpx;
      height: var(--height);
      line-height: var(--height);
      font-size: 28rpx;
      color: #666;
      text-align: center;
      flex-shrink: 0;

      &.active {
        color: #bc2159;
        font-weight: 500;
      }
    }

    .tab-line {
      position: absolute;
      bottom: 0;
      left: 0;
      height: 6rpx;
      background-color: #bc2159;
      border-radius: 6rpx;
      transition: transform 0.3s;
    }
  }

  .tabs-content {
    width: 100%;
    height: calc(100vh - 88rpx);
    overflow: hidden;

    .tabs-content-wrap {
      display: flex;
      width: 100%;
      height: 100%;
      will-change: transform;
    }

    .tab-pane {
      flex: none;
      width: 100%;
      height: 100%;
      padding: 20rpx;
      box-sizing: border-box;
      position: relative;
    }
  }
}
</style>