最近开发过程中遇到一个滑动菜单模块,因为以前没有接触过,市场上还没有完全符合要求的组件,开发过程中还遇到很多问题,今天趁着有时间,总结一下,我们先一下实现后的效果(源代码我会放在文章的最后):

我的项目是使用uni-app开发,主要使用vue3语句来实现,使用的组件库uView-plus这个组件库中也有对应的组件,但是代码内部仍然使用的是vue2的语法,在我这项目中使用会报错,所以干脆就把整个源文件下载下来,然后修改源码。下面是我遇到的几个问题以及解决方案,希望能帮助到大家。
首先是解决源代码中的this问题
因为vue3的代码中是没有this的,而这两个函数中都有用到这个方法,uni.createSelectorQuery().in(this)

解决办法是用vue中的getCurrentInstance这个方法来获取到当前组件的实例
js
const currentInstance = ref(); // 组件实例
onMounted(() => {
currentInstance.value = getCurrentInstance(); // 获取组件实例
});
下面是修改后的代码:
js
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(currentInstance.value);
query
.select('.' + elClass)
.boundingClientRect((rect) => {
if (!rect) {
setTimeout(() => {
getElRect(elClass, dataVal);
}, 10);
return;
}
if (dataVal == 'menuHeight') menuHeight.value = rect.height;
if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;
dataVal = rect.height;
resolve();
})
.exec();
});
};
// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
return new Promise((resolve) => {
let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
setTimeout(() => {
selectorQuery
.selectAll('.search-item')
.boundingClientRect((rects) => {
if (!rects.length) {
setTimeout(() => {
getMenuItemTop();
}, 10);
return;
}
rects.forEach((rect) => {
arr.value.push(rect.top - rects[0].top);
resolve();
});
})
.exec();
}, 100);
});
**};**
菜单栏位置匹配,会出现上下乱动的情况

这个问题出现在下面的函数中(修改后)
js
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
oldScrollTop.value = e.detail.scrollTop;
if (arr.value.length === 0) {
await getMenuItemTop();
}
if (timer.value) return;
if (!menuHeight.value) {
await getElRect('menu-scroll-view', 'menuHeight');
}
setTimeout(() => {
timer.value = null;
let scrollHeight = e.detail.scrollTop;
for (let i = 0; i < arr.value.length; i++) {
let height1 = Math.floor(arr.value[i]);
let height2 = Math.floor(arr.value[i + 1]);
if (scrollHeight >= height1 && scrollHeight < height2) {
leftMenuStatus(i);
return;
}
if (!height2) {
leftMenuStatus(5);
}
}
}, 10);
};
我们发现leftMenuStatus传递的i值与点击的i值始终是不同的,也就是说右侧在点击的时候计算高度出现了问题


顺藤摸瓜,发现getElRect这个函数获取的menuHeight左侧菜单的总高度的值使用为0,便修改了getElRect这个函数
javascript
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(currentInstance.value);
query
.select('.' + elClass)
.boundingClientRect((rect) => {
if (!rect) {
setTimeout(() => {
getElRect(elClass, dataVal);
}, 10);
return;
}
dataVal = rect.height
resolve();
})
.exec();
});
};
原因就出现在这行dataVal = rect.height代码中,我们获取的高度没有问题,因为我们是vue3的代码,所以这样赋值就会出现错误,没办法将值正确的与变量绑定。 修改后:
ini
//将原本的dataVal = rect.height修改成下面的代码
if (dataVal == 'menuHeight') menuHeight.value = rect.height;
if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;
这样就可以拿到menuHeight左侧菜单栏总高度了
点击左侧菜单栏的时候右侧不动

我们发现原来是这个函数getMenuItemTop在获取右侧距离顶部的距离的数组的时候,结果为0。原因是当我们滚动屏幕的时候,这个时候DOM元素还没有创建完成,所以获取的值就是0。解决办法:设置一个定时器,等待一定时间再去获取

scss
// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
return new Promise((resolve) => {
let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
setTimeout(() => {
selectorQuery
.selectAll('.search-item')
.boundingClientRect((rects) => {
if (!rects.length) {
setTimeout(() => {
getMenuItemTop();
}, 10);
return;
}
rects.forEach((rect) => {
arr.value.push(rect.top - rects[0].top);
resolve();
});
})
.exec();
}, 100);
});
};
这样这个问题就解决了

左右不匹配

这个问题就在于rightScroll函数中我们在计算右侧滚动距离的时候传递的i值不正确。我们只需要修改一下我们的左右匹配机制即可(将原本需要对比的滚动距离修改成滑轮滚动的距离即可)。
js
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
oldScrollTop.value = e.detail.scrollTop;
if (arr.value.length === 0) {
await getMenuItemTop();
}
if (timer.value) return;
if (!menuHeight.value) {
await getElRect('menu-scroll-view', 'menuHeight');
}
setTimeout(() => {
timer.value = null;
let scrollHeight = e.detail.scrollTop;
for (let i = 0; i < arr.value.length; i++) {
let height1 = Math.floor(arr.value[i]);
let height2 = Math.floor(arr.value[i + 1]);
if (!height2 && scrollHeight >= height1 && scrollHeight < height2) {
leftMenuStatus(i);
return;
}
}
}, 10);
};
这样这个组件就可以使用了 如果还有什么bug欢迎大家来留言
修改后源代码(适用vue3)
js
<template>
<view class="u-wrap">
<view class="u-search-box" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<view class="u-search-inner">
<u-icon name="search" color="#909399" :size="28"></u-icon>
<text class="u-search-text">搜索</text>
</view>
</view>
<view class="u-menu-wrap">
<scroll-view scroll-y scroll-with-animation class="u-tab-view menu-scroll-view" :scroll-top="scrollTop" :scroll-into-view="itemId">
<view
v-for="(item, index) in tabbar"
:key="index"
class="u-tab-item"
:class="[current == index ? 'u-tab-item-active' : '']"
@tap.stop="swichMenu(index)">
<text class="u-line-1">{{ item.name }}</text>
</view>
</scroll-view>
<scroll-view :scroll-top="scrollRightTop" scroll-y scroll-with-animation class="right-box menuHeight" @scroll="rightScroll">
<view class="page-view">
<view class="class-item menuList" :id="'item' + index" v-for="(item, index) in tabbar" :key="index">
<view class="item-title">
<text>{{ item.name }}</text>
</view>
<view class="item-container">
<view v-if="item.name == '价格范围'" class="roomPrice">
<view class="priceMsg search-item">
<text :style="{ color: price >= 20 ? '#3DC5FB' : 'black' }">0</text>
<text :style="{ color: price >= 20 ? '#3DC5FB' : 'black' }">¥100</text>
<text :style="{ color: price >= 40 ? '#3DC5FB' : 'black' }">¥200</text>
<text :style="{ color: price >= 60 ? '#3DC5FB' : 'black' }">¥300</text>
<text :style="{ color: price >= 80 ? '#3DC5FB' : 'black' }">¥400</text>
<text :style="{ color: price >= 100 ? '#3DC5FB' : 'black' }">¥500+</text>
</view>
<u-slider v-model="price" step="20" @change="changePrice"></u-slider>
</view>
<view v-if="item.name == '房源户型' && searchObj" class="roomType search-item">
<view v-for="item in housType" :key="item">
<view
:class="{ 'typeList-activ': searchObj.housTypeVal == item.val, typeList: true }"
@click="choiceHouseType(item.val)">
{{ item.name }}
</view>
</view>
</view>
<view v-if="item.name == '出租类型' && searchObj" class="roomType search-item">
<view
:class="{ 'typeList-activ': searchObj.houseType.rentMode == 1, typeList: true }"
@click="searchObj.houseType.rentMode = 1">
<text>独享整套</text>
</view>
<view
:class="{ 'typeList-activ': searchObj.houseType.rentMode == 2, typeList: true }"
@click="searchObj.houseType.rentMode = 2">
<text>单间出租</text>
</view>
</view>
<view v-if="item.name == '设施' && searchObj" class="roomType search-item">
<view
:class="{ 'typeList-activ': searchObj.otherArr.includes(item.id), typeList: true }"
v-for="item in facilitiesArr"
:key="item.id"
@click="appendOtherList(item.id)">
{{ item.name }}
</view>
<view :class="{ 'typeList-activ': searchObj.otherArr.includes(1049), menuList: true }" @click="appendOtherList(1049)">
洗发水/沐浴露
</view>
</view>
<view v-if="item.name == '服务' && searchObj" class="roomType search-item">
<view
v-for="item in serveArr"
:key="item.id"
:class="{ 'typeList-activ': searchObj.otherArr.includes(item.id), menuList: true }"
@click="appendOtherList(item.id)">
{{ item.name }}
</view>
</view>
<view v-if="item.name == '用途' && searchObj" class="roomType search-item">
<view
@click="searchRequreArr(item.id)"
:class="{
'typeList-activ': addrequeryArr.includes(item.id) || searchObj.addrequeryArr.includes(item.id),
typeList: true,
}"
v-for="item in requireArr"
:key="item.id">
{{ item.name }}
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="detail-btn">
<view>
<u-button
@click="cancellation"
:custom-style="{
width: '187.5rpx',
height: '97.22rpx',
borderRadius: '18rpx',
}"
>取消</u-button
>
</view>
<view>
<u-button
:custom-style="{
width: '458.33rpx',
height: '97.22rpx',
background: '#ff960c',
borderRadius: '18rpx',
color: '#fff',
}"
@click="sendInfo"
>确定</u-button
>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { onLoad, onShow, onReady } from '@dcloudio/uni-app';
import mallmenu from './mallMenu.vue';
import { getCurrentInstance } from 'vue';
const props = defineProps({
searchObj: Object,
});
const menuCompoments = ref(null); // 得到组件本身实例
const currentInstance = ref(); // 组件实例
const emit = defineEmits(['closePopup', 'sendSearObj']);
const scrollTop = ref(0); // 用于控制左侧菜单滚动位置的响应式变量。
const oldScrollTop = ref(0); // 用于存储右侧内容区上次的滚动位置。
const current = ref(0); // 表示当前激活的菜单项的索引。
const menuHeight = ref(0); //左侧菜单的总高度。
const menuItemHeight = ref(0); //单个菜单项的高度。
const itemId = ref(''); //用于右侧内容滚动定位的元素 ID。
const arr = ref([]); //存储每个右侧内容项顶部距离的数组。
const scrollRightTop = ref(0); //控制右侧内容区滚动位置的响应式变量。
const timer = ref(null); //用于函数节流的定时器变量。
const { safeAreaInsets } = uni.getSystemInfoSync();
const price = ref(20); // 价格默认值
const searchObj = ref(); // 搜索条件对象
const housTypeVal = ref(); // 控制房源户型条件
const addrequeryArr = ref([]); // 需要添加的数组id列表
const tabbar = ref([{ name: '价格范围' }, { name: '房源户型' }, { name: '出租类型' }, { name: '设施' }, { name: '服务' }, { name: '用途' }]);
// 房源户型
const housType = ref([
{ name: '1室', val: 1 },
{ name: '1室1厅', val: 1.1 },
{ name: '2室', val: 2 },
{ name: '4室', val: 4 },
{ name: '2室1厅', val: 2.1 },
{ name: '3室', val: 3 },
]);
// 用途数组
const requireArr = ref([
{ id: 1068, name: '接待婴儿' },
{ id: 1069, name: '接待儿童' },
{ id: 1070, name: '接待老人' },
{ id: 1071, name: '接待外宾' },
{ id: 1072, name: '允许吸烟' },
{ id: 1073, name: '携带宠物' },
{ id: 1074, name: '允许做饭' },
{ id: 1075, name: '允许聚会' },
{ id: 1076, name: '商业拍摄' },
]);
// 设施数组
const facilitiesArr = ref([
{ id: 1035, name: '电梯' },
{ id: 1047, name: '暖气' },
{ id: 1031, name: '电视' },
{ id: 1036, name: '厨房' },
{ id: 1037, name: '书房' },
{ id: 1038, name: '阳台' },
{ id: 1048, name: '浴巾' },
{ id: 1050, name: '电吹风' },
]);
// 服务数组
const serveArr = ref([
{ id: 1029, name: '无线网络' },
{ id: 1032, name: '独立卫浴' },
{ id: 1041, name: '提供餐饮' },
{ id: 1039, name: '免费停车位' },
{ id: 1040, name: '付费停车位' },
{ id: 1027, name: '可洗热水澡' },
{ id: 1042, name: '支团建会议' },
]);
onMounted(() => {
currentInstance.value = getCurrentInstance(); // 获取组件实例
getMenuItemTop();
if (props.searchObj) searchObj.value = props.searchObj;
dateEcho();
});
/*
当用户点击左侧菜单项时调用。它首先确保所有右侧内容项的顶部距离已被计算和存储。然后,如果选中的菜单项与当前激活的菜单项不同,
它会更新 scrollRightTop 来滚动右侧内容区,使选中的内容项可见,并调用 leftMenuStatus 来更新左侧菜单的状态。
*/
const swichMenu = async (index) => {
if (arr.value.length === 0) {
await getMenuItemTop();
}
if (index === current.value) return;
scrollRightTop.value = oldScrollTop.value;
nextTick(() => {
scrollRightTop.value = arr.value[index];
current.value = index;
leftMenuStatus(index);
});
};
/*
此函数用于获取指定元素的高度。它创建一个选择器查询(createSelectorQuery),
获取特定类的元素高度,并将这个值保存在指定的响应式变量中。这对于计算滚动位置非常重要。
*/
const getElRect = async (elClass, dataVal) => {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(currentInstance.value);
query
.select('.' + elClass)
.boundingClientRect((rect) => {
if (!rect) {
setTimeout(() => {
getElRect(elClass, dataVal);
}, 10);
return;
}
if (dataVal == 'menuHeight') menuHeight.value = rect.height;
if (dataVal == 'menuItemHeight') menuItemHeight.value = rect.height;
dataVal = rect.height;
resolve();
})
.exec();
});
};
const observer = () => {
tabbar.value.map((val, index) => {
let observer = uni.createIntersectionObserver(currentInstance.value);
// 检测右边scroll-view的id为itemxx的元素与right-box的相交状态
// 如果跟.right-box底部相交,就动态设置左边栏目的活动状态
observer
.relativeTo('.right-box', {
top: 0,
})
.observe('#item' + index, (res) => {
if (res.intersectionRatio > 0) {
let id = res.id.substring(4);
leftMenuStatus(id);
}
});
});
};
//根据当前选中的菜单项索引来更新左侧菜单的状态。此方法计算 scrollTop 以确保选中的菜单项在左侧菜单中垂直居中。
// menuHeight:左侧菜单总高度 menuItemHeight:单个菜单的高度
const leftMenuStatus = async (index) => {
current.value = index;
if (menuHeight.value === 0 || menuItemHeight.value === 0) {
await getElRect('menu-scroll-view', 'menuHeight');
await getElRect('u-tab-item', 'menuItemHeight');
}
// scrollTop:控制左侧菜单滚动位置
scrollTop.value = index * menuItemHeight.value + menuItemHeight.value / 2 - menuHeight.value / 2;
};
// 获取并存储右侧每个内容项的顶部位置。这对于后续根据滚动位置判断当前激活的菜单项非常关键。
const getMenuItemTop = () => {
return new Promise((resolve) => {
let selectorQuery = uni.createSelectorQuery().in(currentInstance.value);
setTimeout(() => {
selectorQuery
.selectAll('.search-item')
.boundingClientRect((rects) => {
if (!rects.length) {
setTimeout(() => {
getMenuItemTop();
}, 10);
return;
}
rects.forEach((rect) => {
arr.value.push(rect.top - rects[0].top);
resolve();
});
})
.exec();
}, 100);
});
};
/*
当用户滚动右侧内容区时触发。它首先确保所有必要的尺寸信息都已经获取。然后使用一个简单的节流机制
(通过 timer)来限制事件处理的频率。该方法根据当前滚动位置更新左侧菜单的选中状态。
*/
const rightScroll = async (e) => {
oldScrollTop.value = e.detail.scrollTop;
if (arr.value.length === 0) {
await getMenuItemTop();
}
if (timer.value) return;
if (!menuHeight.value) {
await getElRect('menu-scroll-view', 'menuHeight');
}
setTimeout(() => {
timer.value = null;
let scrollHeight = e.detail.scrollTop;
for (let i = 0; i < arr.value.length; i++) {
let height1 = Math.floor(arr.value[i]);
let height2 = Math.floor(arr.value[i + 1]);
if (scrollHeight >= height1 && scrollHeight < height2) {
leftMenuStatus(i);
return;
}
if (!height2) {
leftMenuStatus(5);
}
}
}, 10);
};
// 用于接受数据进行回显
const dateEcho = () => {
price.value = props.searchObj.priceRange[1] / 500;
};
// 搜索金额变化
const changePrice = () => {
searchObj.value.priceRange = [0, (price.value / 2) * 1000];
if (price.value === 100) searchObj.value.priceRange = [0, 9999];
};
// 点击取消
const cancellation = () => {
emit('closePopup', false);
};
// 点击确定
const sendInfo = () => {
emit('sendSearObj', searchObj.value);
};
// 选择房源户型
const choiceHouseType = (val) => {
housTypeVal.value = val;
searchObj.value.houseType.bedRoomCount = +val.toFixed(0);
searchObj.value.housTypeVal = val;
};
// 选择其它数组
const searchRequreArr = (val) => {
if (!addrequeryArr.value.includes(val)) {
addrequeryArr.value.push(val);
} else {
addrequeryArr.value = addrequeryArr.value.filter((item) => item !== val);
}
searchObj.value.requireArr = addrequeryArr.value.map((item) => ({
id: String(item),
checked: '1',
}));
searchObj.value.addrequeryArr = addrequeryArr.value;
};
// 点击其它按钮,将id存放到数组中
const appendOtherList = (val) => {
if (!searchObj.value.otherArr.includes(val)) {
searchObj.value.otherArr.push(val);
} else {
searchObj.value.otherArr = searchObj.value.otherArr.filter((item) => item !== val);
}
};
</script>
<style lang="scss" scoped>
.u-wrap {
height: calc(100vh);
/* #ifdef H5 */
height: calc(100vh - var(--window-top));
/* #endif */
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-top: 20rpx;
background-color: #f6f6f6;
}
.u-search-box {
display: none;
}
.u-menu-wrap {
flex: 1;
display: flex;
overflow: hidden;
}
.u-search-inner {
background-color: rgb(234, 234, 234);
border-radius: 100rpx;
display: flex;
align-items: center;
padding: 10rpx 16rpx;
}
.u-search-text {
font-size: 26rpx;
color: $u-tips-color;
margin-left: 10rpx;
}
.u-tab-view {
width: 200rpx;
height: 100%;
}
.u-tab-item {
height: 110rpx;
background: #f6f6f6;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: #444;
font-weight: 400;
line-height: 1;
}
.u-tab-item-active {
position: relative;
color: #000;
font-size: 30rpx;
font-weight: 600;
background: #fff;
}
.u-tab-item-active::before {
content: '';
position: absolute;
border-left: 4px solid $u-primary;
height: 32rpx;
left: 0;
top: 39rpx;
}
.u-tab-view {
height: 100%;
}
.right-box {
background-color: rgb(250, 250, 250);
}
.page-view {
padding: 16rpx;
}
.class-item {
margin-bottom: 30rpx;
background-color: #fff;
padding: 16rpx;
border-radius: 8rpx;
}
.class-item:last-child {
min-height: 100vh;
}
.item-title {
font-size: 26rpx;
color: $u-main-color;
font-weight: bold;
margin-bottom: 20rpx;
}
.item-menu-name {
font-weight: normal;
font-size: 24rpx;
color: $u-main-color;
}
.item-container {
display: flex;
flex-wrap: wrap;
.roomPrice {
width: 100%;
.priceMsg {
display: flex;
justify-content: space-between;
font-size: 25rpx;
margin-left: 34.72rpx;
}
}
}
.thumb-box {
width: 33.333333%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-top: 20rpx;
}
.item-menu-image {
width: 120rpx;
height: 120rpx;
}
.roomType {
display: flex;
flex-wrap: wrap;
.typeList {
width: 152.78rpx;
height: 69.44rpx;
margin: 0 20.83rpx 13.89rpx 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #f0f1f3;
font-size: 14px;
font-weight: 300;
}
.menuList {
box-sizing: border-box;
margin: 0 20.83rpx 13.89rpx 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #f0f1f3;
font-size: 14px;
font-weight: 300;
padding: 22.92rpx;
}
.typeList-activ {
background-image: url(https://rizuwang-1316974425.cos.ap-beijing.myqcloud.com/mobilePics/home/background.png);
background-repeat: no-repeat;
background-position-x: 5px;
background-size: 22.22rpx 22.22rpx;
background-position: bottom right;
background-color: #dcf5ff;
}
}
.detail-btn {
margin-top: 50rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-around;
}
</style>