左右列表联动
电商类、外卖类web或者小程序常用,左侧点击对应的tag,右侧会滚动到对应的内容上;右侧滚动,左侧也会到指定位置高亮。
前言
容易出错的是,在获取元素时,需要在DOM加载完毕后获取,这里会涉及到生命周期的知识。
实现
这里对css样式不做过多赘述,css我会放在下面,有兴趣的朋友可以自行查看,只针对功能实现。
-
点击高亮
首先左右联动的实现是基于点击高亮,列表滚动,这两个基础功能来实现的。所以我在获取了后端传来的数据后,使用active和index去确定我具体点击了哪个位置,就让哪个位置高亮,代码如下:
<scroll-view scroll-y="true" :style="{ 'height':scrollHeight + 'px' }" :scroll-with-animation="false">
<view class="item"
v-for="(item,index) in cateData" :key="index"
:class="{ 'active':index === leftIndex }"
:id="'left-'+index"
@tap="leftTap(index)">
{
{item.name}}
</view>
</scroll-view>
/* 左侧导航点击 */
leftTap(e){
this.leftIndex = e
this.$emit('getInto', "item-" + e)
}
左边列表的高度,我使用了动态高度,这是由父组件获取其他元素高度后计算得出的,得到高度后使用组件传值方式,再传递回来。也是方便后续如果页面发生了改变,新增了其他组件进而对高度进行调整。
class:
当我触发了点击事件后,我将获取到点击的具体某一项元素的index重新赋值,然后给出判断
:class="{ 'active':index === leftIndex }"
这个条件成立,我就调用名为active
的样式。而后面的this.$emit
是将点击后的index拼接传到父组件中,因为在右侧列表的位置判断上我需要用到。
2.右侧列表滚动
滚动我使用了原生的scroll-view
,代码如下:
<scroll-view scroll-y="true"
:style="{ 'height':scrollHeight + 'px' }"
:scroll-into-view="scrollInto"
:scroll-with-animation="false"
@scroll="mainScroll">
<view class="item main-item" v-for="(item,index) in cateName" :key="index" :id="'item-'+index" @tap="click">
<view class="title">
<view>{
{item.cateName}}</view>
</view>
<view class="goods" v-if="item.cateId === item2.cateId" v-for="(item2,index2) in listData"
:key="index2">
<image :src="item2.logo" mode=""></image>
<view>
<view>{
{item2.name}}</view>
<view class="describe">{
{item2.subname}}</view>
</view>
</view>
</view>
<view class="fill-last" :style="{ 'height':fillHeight + 'px' }"></view>
</scroll-view>
这里比较关键的地方就是:scroll-into-view="scrollInto"
,通过文档可知:
这个参数是用来控制,滚动区域具体显示某个子元素的。如果我在左侧点击的时候就能知道我要显示的右侧列表的中的某一项的子元素id的话,是不是就能实现,点击后,右侧显示具体的内容了呢?
答案是肯定的。
上面我们说到了,将事件使用$emit抛出,那么在父组件中就能够获取到,
<!-- 滚动区域 -->
<tool-wap-left-list
ref="ToolWapLeftList"
:scrollHeight="scrollHeight"
@getInto="getval">
</tool-wap-left-list>
<tool-wap-content-list
ref="ToolWapContentList"
:scrollHeight="scrollHeight"
:scrollTopSize="scrollTopSize"
@getIndex="getIndex">
</tool-wap-content-list>
getval(val) {
this.$refs.ToolWapContentList.scrollInto = val
}
这里我们就能够通过改变scrollInto
进而控制右侧滚动的位置了。
当然,我们首先要先获取到右侧滚动区域的所有元素和它们的顶部高度才行。
/* 获取元素顶部信息 */
getElementTop() {
console.log('this.scrollHeight', this.scrollHeight);
new Promise((resolve, reject) => {
let view = uni.createSelectorQuery().selectAll('.main-item');
view.boundingClientRect(data => {
resolve(data);
}).exec();
}).then((res) => {
let topArr = res.map((item) => {
return item.top - this.scrollTopSize; /* 减去滚动容器距离顶部的距离 */
});
this.topArr = topArr;
/* 获取最后一项的高度,设置填充高度。判断和填充时做了 +-20 的操作,是为了滚动时更好的定位 */
let last = res[res.length - 1].height;
if (last - 20 < this.scrollHeight) {
this.fillHeight = this.scrollHeight - last + 20;
}
});
},
这里对滚动到最后元素时,不满页面做了一个填充。
同理,左侧的高亮我们也可以通过该逻辑实现。
3.滚动高亮
/* 主区域滚动监听 */
mainScroll(e) {
// 节流方法
clearTimeout(this.mainThrottle);
this.mainThrottle = setTimeout(() => {
scrollFn();
}, 10);
let scrollFn = () => {
let top = e.detail.scrollTop;
let index = 0;
/* 查找当前滚动距离 */
for (let i = (this.topArr.length - 1); i >= 0; i--) {
/* 在部分安卓设备上,因手机逻辑分辨率与rpx单位计算不是整数,滚动距离与有误差,增加2px来完善该问题 */
if ((top + 2) >= this.topArr[i]) {
index = i;
break;
}
}
this.leftIndex = (index < 0 ? 0 : index);
this.$emit('getIndex', this.leftIndex)
}
},
以上就是左右联动的实现方法了,下面也会把详细的代码贴上去,感兴趣的朋友也可以研究研究。代码如下:
<template>
<view class="container">
<!-- 顶部面板 -->
<view class="top--panel">
<!-- 顶部面板,可添加所需要放在页面顶部的内容代码。比如banner图 -->
<view style="background-color: #ffaa00;text-align: center;font-size: 28rpx;padding: 10px 0;color: #fff;">
<view>这里顶部内容占位区域,不需要则删除</view>
<view>可添加需放在页面顶部的内容,比如banner、轮播图等</view>
</view>
</view>
<!-- 滚动区域 -->
<view class="scroll-panel" id="scroll-panel">
<view class="list-box">
<view class="left">
<scroll-view scroll-y="true"
:style="{ 'height':scrollHeight + 'px' }"
:scroll-with-animation="false"
>
<view class="item"
v-for="(item,index) in cateData"
:key="index"
:class="{ 'active':index===leftIndex }"
:id="'left-'+index"
@tap="leftTap(index)"
>{
{item.name}}</view>
</scroll-view>
</view>
<view class="main">
<scroll-view scroll-y="true" :style="{ 'height':scrollHeight + 'px' }" @scroll="mainScroll" :scroll-into-view="scrollInto" :scroll-with-animation="false">
<view class="item main-item" v-for="(item,index) in cateName" :key="index" :id="'item-'+index" @tap="click">
<view class="title">
<view>{
{item.cateName}}</view>
</view>
<view class="goods" v-if="item.cateId === item2.cateId" v-for="(item2,index2) in listData" :key="index2">
<image :src="item2.logo" mode=""></image>
<view>
<view>{
{item2.name}}</view>
<view class="describe">{
{item2.subname}}</view>
</view>
</view>
</view>
<view class="fill-last" :style="{ 'height':fillHeight + 'px' }"></view>
</scroll-view>
</view>
</view>
</view>
<!-- 底部面板 -->
<view class="bottom-panel">
<!-- 底部面板,可添加所需要放在页面底部的内容代码。比如购物车栏目 -->
<view style="background-color: #ffaa00;text-align: center;font-size: 28rpx;padding: 10px 0;color: #fff;">
<view>这里底部内容占位区域,不需要则删除</view>
<view>可添加需放在页面底部的内容,比如购物车栏目</view>
</view>
</view>
</view>
</template>
<script>
import { promises } from 'fs'
export default {
data() {
return {
scrollHeight:400, // 默认高度
scrollTopSize:0, // 默认顶部高度
fillHeight:0, // 填充高度,用于最后一项低于滚动区域时使用
topArr:[], // 左侧列表元素
leftIndex:0, // 点击切换存储的下标
scrollInto:'', // 左侧定位
cateData: [],
cateName: [],
listData: []
}
},
created() {
this.onRightListFetch()
this.onLeftListFetch()
// 这里如果上面请求超过500ms同样获取不到元素
// setTimeout(() => {
// console.log('this.cateData',this.cateData);
// this.getElementTop()
// this.initScrollView()
// }, 500)
},
methods: {
/* 初始化滚动区域 */
initScrollView(){
return new Promise((resolve, reject)=>{
let view = uni.createSelectorQuery().select('#scroll-panel');
view.boundingClientRect(res => {
this.scrollTopSize = res.top;
this.scrollHeight = res.height;
setTimeout(()=>{
resolve();
},100);
}).exec();
});
},
// 获取右边数据
async onRightListFetch() {
const res = await this.$store.dispatch("tool/getToolList", {
});
if ([100].includes(res.code)) {
this.listTotal = res.result.tltal
this.listData = res.result.data || []
let map = new Map();
for (let item of this.listData) {
map.set(item.cateId, item);
}
this.cateName = [...map.values()].sort((a,b) => {return a.cateId - b.cateId});
this.$nextTick(()=> {
this.getElementTop()
this.initScrollView()
})
} else {
this.$toast(res.msg);
}
},
// 获取左边数据
async onLeftListFetch() {
const res = await this.$store.dispatch('tool/getCateList')
if ([100].includes(res.code)) {
this.cateTotal = res.result.total
this.cateData = res.result.data.sort((a,b) => {return a.id - b.id}) || []
this.loading = false
} else {
this.$toast(res.msg)
}
},
/* 获取元素顶部信息 */
getElementTop(){
new Promise((resolve,reject)=>{
let view = uni.createSelectorQuery().selectAll('.main-item');
view.boundingClientRect(data => {
resolve(data);
}).exec();
}).then((res)=>{
console.log('res',res);
let topArr = res.map((item)=>{
return item.top - this.scrollTopSize; /* 减去滚动容器距离顶部的距离 */
});
this.topArr = topArr;
/* 获取最后一项的高度,设置填充高度。判断和填充时做了 +-20 的操作,是为了滚动时更好的定位 */
let last = res[res.length-1].height;
if(last - 20 < this.scrollHeight){
this.fillHeight = this.scrollHeight - last + 20;
}
});
},
/* 主区域滚动监听 */
mainScroll(e){
// 节流方法
clearTimeout(this.mainThrottle);
this.mainThrottle = setTimeout(()=>{
scrollFn();
},10);
let scrollFn = ()=>{
let top =e.detail.scrollTop;
let index=0;
/* 查找当前滚动距离 */
this.topArr.forEach((item,id) => {
if((top+2)>=item){
index = id;
this.leftIndex=(index < 0 ? 0: index);
}
})
}
},
/* 左侧导航点击 */
leftTap(e){
this.leftIndex = e
this.scrollInto="item-" + e;
}
}
}
</script>
<style lang="scss">
page{
height: 100vh;
}
.container{
height: 100%;
}
/* 容器 */
.container{
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
&>view{
width: 100%;
}
.scroll-panel{
flex-grow: 1;
height: 0;
overflow: hidden;
}
.bottom-panel{
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
}
.list-box{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
font-size: 28rpx;
.left{
width: 200rpx;
background-color: #f6f6f6;
line-height: normal;
box-sizing: border-box;
font-size: 32rpx;
.item{
padding: 30rpx;
position: relative;
& + .item{
margin-top: 1px;
&::after {
content: '';
display: block;
height: 0;
border-top: #d6d6d6 solid 1px;
width: 620upx;
position: absolute;
top: -1px;
right: 0;
transform:scaleY(0.5); /* 1px像素 */
}
}
&.active{
color: #42b983;
background-color: #fff;
position: relative;
&::before{
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
border-left: #42b983 solid 4px;
height: 100%;
width: 0;
}
}
}
.fill-last{
height: 0;
width: 100%;
background: none;
}
}
.main{
background-color: #fff;
padding-left: 20rpx;
width: 0;
flex-grow: 1;
box-sizing: border-box;
.title{
line-height: normal;
padding: 30rpx 0;
font-size: 24rpx;
font-weight: bold;
color: #666;
background-color: #fff;
position: sticky;
top: 0;
z-index: 19;
}
.item{
padding-bottom: 16rpx;
border-bottom: #eee solid 1px;
}
.goods{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
align-content: center;
& + .goods{
margin-top: 16rpx;
}
& > image{
width: 120rpx;
height: 120rpx;
margin-right: 16rpx;
margin-left: 2px;
}
.describe{
font-size: 24rpx;
color: #999;
}
.money{
font-size: 24rpx;
color: #efba21;
}
}
}
}
</style>