这里是文章模块栏目内容页
vue3组件实现tabs选中滚动到当前位置的详解

经常需要再页面里面实现 很多个tabs组成一条选择按钮,点击其中一个按钮,滚动到屏幕中间位置,

然后对应的content 内容也滚动到对应的 位置;

QQ浏览器截图20241129113931

如图的结构;

页面结构,最左侧是一级分类:

右侧,顶部的tabs 是二级分类;

右侧:content部分是 分类标题和 推荐的产品列表; 这个页面结构在电商小程序和h5页面都很常见;

下面是实现这个页面的 方法:

红色方框采用两个组件来实现,组件直接通过 事件传值;

第一个组件tabs

<template>

<scroll-view
:scroll-x="true"
:scroll-left="scrollLeft"
scroll-with-animation
class="u-tabs__wrapper__scroll-view"
:show-scrollbar="false"
ref="u-tabs__wrapper__scroll-view"
>  
<view
ref="u-tabs__wrapper__nav">
 
<view
:class="[ index==innerCurrent && 'active',`u-tabs__wrapper__nav__item-${index}`]"
v-for="(item,index) in list"  :key="index"
@tap="clickHandler(item, index)"
:ref="`u-tabs__wrapper__nav__item-${index}`"
>
<text > {{item.name}}</text>
</view>
 
</view>
</scroll-view>

</template>

<script>
export default{
name : 'CateList',
emits: ['click', 'change'],
props:{
list:{
type:Array,
default:[]
},
current:{
type: Number,
default: 0
}
},
data(){
return {
scrollLeft : 0,
innerCurrent : 0,
}
},
watch: {
current: {
immediate: true,
handler (newValue, oldValue) {
// 内外部值不相等时,才尝试移动滑块
if (newValue !== this.innerCurrent) {
this.innerCurrent = newValue
this.$nextTick(() => {
this.resize()
})
}
}
}
},
async mounted() {
this.init()
},

methods:{
   sleep(value = 30) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, value)
})
} ,
clickHandler(item, index) {
// 因为标签可能为disabled状态,所以click是一定会发出的,但是change事件是需要可用的状态才发出
this.$emit('click', {
...item,
index
})
this.innerCurrent = index;
this.resize()
this.$emit('change', {
...item,
index
})
},
init() {
this.sleep().then(() => {
 
this.resize()
})
},
setScrollLeft() {
// 当前活动tab的布局信息,有tab菜单的width和left(为元素左边界到父元素左边界的距离)等信息
const tabRect = this.list[this.innerCurrent]
// 累加得到当前item到左边的距离
const offsetLeft = this.list
.slice(0, this.innerCurrent)
.reduce((total, curr) => {
return total + curr.rect.width
}, 0)
 
// 此处为屏幕宽度  
const windowWidth =  uni.getSystemInfoSync().windowWidth
// 将活动的tabs-item移动到屏幕正中间,实际上是对scroll-view的移动
let scrollLeft = offsetLeft - (this.tabsRect.width - tabRect.rect.width) / 2 - (windowWidth - this.tabsRect
.right) / 2 + this.tabsRect.left / 2
// 这里做一个限制,限制scrollLeft的最大值为整个scroll-view宽度减去tabs组件的宽度
scrollLeft = Math.min(scrollLeft, this.scrollViewWidth - this.tabsRect.width)
this.scrollLeft = Math.max(0, scrollLeft)
},
// 获取所有标签的尺寸
resize() {

// 如果不存在list,则不处理
if(this.list.length === 0) {
return
}
 
Promise.all([this.getTabsRect(), this.getAllItemRect()]).then(([tabsRect, itemRect = []]) => {
this.tabsRect = tabsRect
this.scrollViewWidth = 0
 
itemRect.map((item, index) => {
// 计算scroll-view的宽度,这里
this.scrollViewWidth += item.width
// 另外计算每一个item的中心点X轴坐标
this.list[index].rect = item
})
 
// 获取了tabs的尺寸之后,设置滑块的位置  
this.setScrollLeft()
})
},
// 获取导航菜单的尺寸
getTabsRect() {
return new Promise(resolve => {
this.queryRect('u-tabs__wrapper__scroll-view').then(size =>  resolve(size)  )
})
},
// 获取所有标签的尺寸
getAllItemRect() {
return new Promise(resolve => {  
const promiseAllArr = this.list.map((item, index) => this.queryRect(
`u-tabs__wrapper__nav__item-${index}`, true))
Promise.all(promiseAllArr).then(sizes =>  resolve(sizes) )
})
},
// 获取各个标签的尺寸
queryRect(el, item) {  
// $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://ijry.github.io/uview-plus/js/getRect.html
// 组件内部一般用this.$uGetRect,对外的为uni.$u.getRect,二者功能一致,名称不同
return new Promise(resolve => {
this.uGetRect(`.${el}`).then(size =>  resolve(size) )
})
 
},
uGetRect(selector, all) {
    return new Promise((resolve) => {
        uni.createSelectorQuery()
            .in(this)[all ? 'selectAll' : 'select'](selector)
            .boundingClientRect((rect) => {
                if (all && Array.isArray(rect) && rect.length) {
                    resolve(rect)
                }
                if (!all && rect) {
                    resolve(rect)
                }
            })
            .exec()
    })
},
}
}
</script>

<style>

.cate-tabs{
display: flex;  
padding-bottom: 10px;  
flex-wrap: nowrap;
justify-content: flex-start;

.sub-cate{
background-color: #F2F2F2;
border-radius: 30px;  
padding: 7px 20px ;
margin-right: 10px;
flex-shrink: 0;

}
.active{
background-color: #C8E1FF;
color: #4A9BF7;
}
}
</style>

第二个组件:

<template>
 <scroll-view
   :scroll-y="true"
  :scroll-x="false"
   :scroll-top="scrollTop"  
   scroll-with-animation
   class="cate-goods cate_scroll_view"
   :show-scrollbar="false" 
  >  
  <view class="" v-for="(cate,index) in cateGoods">
   <view class="cate-title u-margin-bottom-20" :class="[`cate_sub_title-${index}`]" >
    <text class="font-size18 font-weight">{{cate.title}}</text>
   </view>
   <view class="good-items u-margin-bottom-40 "
    v-for="(good, k) in cate.goods"  :key="k" >
    <GoodItem :item="good"></GoodItem>
   </view>
  </view>
 
    
  
 </scroll-view> 
</template>

<script setup>
 import GoodItem from "./good-item.vue";
 
 import {
  ref,
  onMounted,
  onUpdated,
  watchEffect,
  watch,
  provide,
  nextTick
 } from 'vue';
 //定义reactive变量类似 data
 const scrollTop = ref(0);
 const isFirst = ref(true);
 const scrollViewTop = ref(0);
  //定义属性
 const props =  defineProps({
  currentCate:{
   type: Object,
  },
  // 导航列表
  cateGoods: {
   type: Array,
   default:  [
    {
     title: "门票",
     goods:[
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""}
     ]
    },
    {
     title: "夜宿体验",
     goods:[
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""}
     ]
    },
    {
     title: "成人票",
     goods:[
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""},
      {id:1, title: "研学蛮龙片", logo:"", sub_title:""}
     ]
    }
   ]
  }  
 });
 
 //监听属性变化
 watch( ()=> props.currentCate , (newValue, oldValue)=>{  
  //计算scroll_view 滚动到top的值
  resize().then( (cate) => {
   if(cate){    
    const item =  cate[newValue.index] ;      
    //当前第几个cate 
    if(item && item.rect){
     scrollTop.value = (item.rect?.top || 0 ) - scrollViewTop.value -20 ;      
    }     
   }
  })  
  
 });
  
 // 获取所有标签的尺寸
 function resize() {
   
  // 如果不存在list,则不处理
  if(props.cateGoods.length === 0) {
   return
  }
  
  return new Promise((resolve)=>{
   //第一次记录下每个分类的初始位置;
   if(isFirst.value){ 
    isFirst.value = false;
    Promise.all([getScrollViewRect(), getAllItemRect()]) .then(([viewRect,  itemRect = []]) => { 
      //scroll_view的top位置
      scrollViewTop.value = viewRect.top; 
      //每个分类的top位置
      itemRect.map((item, index) => {      
        props.cateGoods[index].rect = item ;
        // 另外计算每一个item的中心点X轴坐标
      }) 
      
      resolve(props.cateGoods)
    })
   }else{
     resolve(props.cateGoods)
   }
  })
 }
 function getScrollViewRect(){
  return new Promise(resolve => {
   queryRect("cate_scroll_view").then(size=> resolve(size) ) ;
  })
 }
 function getAllItemRect(){
  return new Promise(resolve => {
   const promiseAllArr = props.cateGoods.map((item, index) => queryRect(   
    `cate_sub_title-${index}`, true));
    
   Promise.all(promiseAllArr).then(sizes => resolve(sizes))
  })
 }
 // 获取各个标签的尺寸
 function queryRect(el, item) {  
  return new Promise(resolve => {
    uGetRect(`.${el}`).then(size =>  resolve(size) )
  })
   
 } 
 function uGetRect(selector, all) {
     return new Promise((resolve) => {
         uni.createSelectorQuery()
             .in(this)[all ? 'selectAll' : 'select'](selector)
             .boundingClientRect((rect) => {
                 if (all && Array.isArray(rect) && rect.length) {
                     resolve(rect)
                 }
                 if (!all && rect) {
                     resolve(rect)
                 }
             })
             .exec()
     })
 } 
 
</script>

<style>
</style>

调用组件,组合成这个商品分类展示商品的页面结构:

<template>
<kb-layout :title="title"> 

<view class="navs u-flex">
 
<view class="search u-padding-20">
<up-search v-model="keyword" :clearabled="true" actionText="搜索" :showAction="true"
height="30" searchIconSize="22" @custom="onKeyword" @search="onKeyword"
:animation="true"></up-search>
</view>
 
<view>
<view>
<view class="cate ">
<view> <text></text></view>
<view class="name  u-line-1">
<text>融创乐园</text>
<image src="../../static/henxian.png"></image>
</view>
<view> <text></text></view>

</view>
<view class="cate active">
<view> <text></text></view>
<view>
<text class=" u-line-1">海世界 </text>
<image src="../../static/henxian.png"></image>
</view>
<view> <text></text></view>

</view>

<view class="cate ">
<view> <text></text></view>
<view class="name  u-line-1">
<text>水乐园</text>
<image src="../../static/henxian.png"></image>
</view>
<view> <text></text></view> 
</view>

</view>
<view class="navs-right u-padding-30">

<CateList :list="subcate" @change="changeCate"></CateList>
 
<!-- <view> -->
<CateGood :currentCate="currentCate"></CateGood>
 
</view>
 

</view>
 

</view>
</kb-layout>
</template>

<script>
import GoodItem from "./componets/good-item.vue";
import CateList from "./componets/cate-list.vue";
import CateGood from "./componets/cate-good.vue";
import {
getBiwanPage,
getSuggestGoodsList,
}  from "@/common/api.js";
 
export default {
components:{
GoodItem,
CateList,
CateGood,
},
name:"pageMall",
props:{
title: {
Type: String,
default: ''
}
},
data() {
return {
keyword:"",
goods: [],
subcate:[
{name: '门票',id: 1 },
{name: '夜宿体验',id: 2 },
{name: '成人票',id: 3 },
{name: '研学大本营',id: 4 },
],
scrollTop : 0,
currentTop: 0,
currentCate: undefined,
}
},
methods: {
onKeyword () {},
fetchData() {
getSuggestGoodsList().then( ret =>  this.goods = ret.data)
},
/*滚动商品到对应分类*/
changeCate(item ){
this.currentCate = item;
  
}, 
 
},
mounted() {
this.fetchData();
}
}
</script>

<style>
.navs{
display: flex;
flex-direction: column;
 

.search{
height: 40px;
background-color: #fff;
border-bottom: #f2f2f2 1px solid;
}
.search-list{
display: flex;
height: calc(100vh - 160px);
}

.navs-left{
width: 6rem;
background-color: #F2F2F2;
overflow-x: hidden;
overflow-y: auto;
color: #B3B3B3;
}
.navs-right{
flex:1;
background-color: #fff;

display: flex;
flex-direction: column;

.cate-goods{
overflow-y: auto;
flex: 1;
padding-top: 20px;
 
}
 

}
.cate{
text-align: center;
height: 3.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px; 

.name{
position: relative;

.henxian{
position: absolute;
left:  0;
bottom: -5px;
height: 15px;
width: 130px;
object-fit: cover;
display: none;
}
}
}

.active{
background-color: #fff;
position: relative; 
font-weight: 900; 
z-index: 1;
font-size: 16px;
color: #2F2F2F;

.name .henxian{
display: block;
}

.tra{
position: absolute;
top: -40rpx;
right: 0rpx;
width: 40rpx;
height: 40rpx;
background-color: #f5f5f5;
border-bottom-right-radius: 30rpx;
text{
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
display: inline-block;
background-color: #fff;
z-index: -1;
}
 
}
.bra{
position: absolute;
bottom: -40rpx;
right: 0;
width: 40rpx;
height: 40rpx;
background-color: #f5f5f5;
border-top-right-radius: 30rpx;
text{
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
display: inline-block;
background-color: #fff;
z-index: -1;
}

}

 
}

 
}
</style>

好了,本文内容全部结束:

上一篇:前端项目集成Vite配置一览无余!

下一篇:没有了

更多栏目
相关内容