Skip to content
微信小程序仿点餐二级联动页面

作为page使用

wxml代码

html
<view class="wrap">
  <!-- 左侧分类 -->
  <scroll-view class="left" scroll-y scroll-with-animation scroll-top="{{leftScrollTop}}">
    <view wx:for="{{categoryList}}" wx:key="id" id="left-{{index}}" class="left-item {{currentIndex === index ? 'active' : ''}}" bindtap="selectCategory" data-index="{{index}}">
      {{item.name}}
    </view>
  </scroll-view>

  <!-- 右侧商品 -->
  <scroll-view class="right" scroll-y scroll-with-animation scroll-top="{{rightScrollTop}}" bindscroll="onScroll">
    <view wx:for="{{categoryList}}" wx:key="id" class="section">
      <view class="section-title">{{item.name}}</view>
      <view wx:for="{{item.goods}}" wx:for-item="good" wx:key="id" class="goods-item">
        <view class="info">
          <text class="name">{{good.name}}</text>
          <text class="price">¥{{good.price}}</text>
        </view>
      </view>
    </view>
  </scroll-view>
</view>

wxss代码

css
/* 整页 100% 高度 */
page{height:100%}

.wrap{
  display:flex;
  height:100%;
}

/* 左侧 */
.left{
  width:200rpx;
  background:#f7f7f7;
}
.left-item{
  padding:30rpx 20rpx;
  text-align:center;
  font-size:28rpx;
  color:#333;
}
.left-item.active{
  background:#fff;
  color:#ff5f00;
  font-weight:bold;
}

/* 右侧 */
.right{
  flex:1;
  background:#fff;
}
.section-title{
  padding:20rpx;
  background:#f5f5f5;
  font-size:30rpx;
  color:#333;
}
.goods-item{
  display:flex;
  padding:20rpx;
  border-bottom:1rpx solid #eee;
}
.pic{
  width:120rpx;
  height:120rpx;
  margin-right:20rpx;
  border-radius:8rpx;
}
.info{
  display:flex;
  flex-direction:column;
  justify-content:center;
}
.name{
  font-size:30rpx;
  color:#333;
}
.price{
  font-size:28rpx;
  color:#ff5f00;
  margin-top:10rpx;
}

js代码

js
let timer = null; // 用于存储节流定时器
let scrollTop = 0;
Page({
  data: {
    categoryList: [],
    currentIndex: 0,
    rightScrollTop: 0,
    leftScrollTop: 0,
    heightArr: [], // 右侧各分类绝对高度
    lockScroll: false, // 防止 onScroll 在手动滚动期间干扰
    jumpNum: 0, // 点击左侧按钮,让目标标签滚动到距离上方第几个格子,0代表第0个格子,也就是直接滚动到最上方
  },

  onLoad() {
    // 这个高度可以做个回调函数,每次点击左侧或者滑动右侧都计算一下高度,返回了数据之后在执行后面的,因为我这里数据都是死数据,所以没有动态获取,理论是动态获取的
    this.setData({
      categoryList: this.mockData()
    }, this.calcHeight);
  },

  /* 点击左侧分类:右侧跳到对应分类顶部,左侧滚到可视中间 */
  async selectCategory(e) {
    const index = e.currentTarget.dataset.index;

    /* 1. 右侧滚到分类顶部 */
    this.setData({
      currentIndex: index,
      lockScroll: true
    });

    // 左侧跟随滚动
    this.scrollLeft(index)

    await this.sleep(300)
    this.setData({
      rightScrollTop: this.data.heightArr[this.data.currentIndex],
    })

    await this.sleep(1000)
    this.setData({
      lockScroll: false
    })
  },
  scrollLeft(index) {
    wx.createSelectorQuery()
      .in(this)
      .select('.left')
      .scrollOffset(res => {
        const currentScrollTop = res.scrollTop;
        wx.createSelectorQuery()
          .in(this)
          .select(`#left-${index}`) // 关键:这里改成真正的 class
          .boundingClientRect(rect => {
            if (!rect) return;
            const h = rect.height; // 单行真实高度
            const need = currentScrollTop + rect.top - this.data.jumpNum * h; // 向上多留两个
            this.setData({
              leftScrollTop: Math.max(0, need)
            });
          })
          .exec();
      })
      .exec();
  },
  // 防抖函数
  sleep(ms) {
    clearTimeout(timer);
    timer = null
    return new Promise((resolve, reject) => {
      timer = setTimeout(() => {
        resolve();
      }, ms);
    });
  },
  /* 右侧滚动时联动左侧高亮(带锁)*/
  async onScroll(e) {
    if(this.data.lockScroll) return
    scrollTop = e.detail.scrollTop;
    await this.sleep(800)
    const arr = this.data.heightArr;
    let current = 0;
    for (let i = 0; i < arr.length; i++) {
      if (scrollTop >= arr[i] - 50) current = i;
      else break;
    }
    if (current !== this.data.currentIndex) {
      this.setData({
        currentIndex: current
      });
      // 左侧跟随滚动
      this.scrollLeft(current)
    }
  },

  /* 计算右侧每个分类到顶部绝对高度 */
  calcHeight() {
    const query = wx.createSelectorQuery().in(this);
    let acc = 0;
    let arr = [0];
    query.selectAll('.section').boundingClientRect(rects => {
      rects.forEach(item => {
        acc += item.height;
        arr.push(acc);
      });
      this.setData({
        heightArr: arr
      });
    }).exec();
  },

  /* 30 个分类 Demo */
  mockData() {
    const names = Array.from({
      length: 30
    }, (_, i) => `分类${i + 1}`);
    return names.map((n, idx) => ({
      id: idx + 1,
      name: n,
      goods: Array.from({
        length: 6
      }).map((_, i) => ({
        id: `${idx}-${i}`,
        name: `${n}-商品${i + 1}`,
        price: (Math.random() * 30 + 5).toFixed(2),
      }))
    }));
  }
});

作为组件使用

组件相关代码

组件的wxml文件

html
<view class="wrap">
  <!-- 左侧分类 -->
  <scroll-view class="left" scroll-y scroll-with-animation scroll-top="{{leftScrollTop}}">
    <view wx:for="{{categoryList}}" wx:key="id" class="left-item left-{{index}} {{currentIndex === index ? 'active' : ''}}" bindtap="selectCategory" data-index="{{index}}">
      {{item.name}}
    </view>
  </scroll-view>

  <!-- 右侧商品 -->
  <scroll-view class="right" scroll-y scroll-with-animation scroll-top="{{rightScrollTop}}" bindscroll="onScroll">
    <view wx:for="{{categoryList}}" wx:key="id" class="section">
      <view class="section-title">{{item.name}}</view>
      <view wx:for="{{item.goods}}" wx:for-item="good" wx:key="id" class="goods-item">
        <view class="info">
          <text class="name">{{good.name}}</text>
          <text class="price">¥{{good.price}}</text>
        </view>
      </view>
    </view>
  </scroll-view>
</view>

组件的wxss文件

css
/* 整页 100% 高度 */
page{height:100%}

.wrap{
  display:flex;
  height:100%;
}

/* 左侧 */
.left{
  width:200rpx;
  background:#f7f7f7;
}
.left-item{
  padding:30rpx 20rpx;
  text-align:center;
  font-size:28rpx;
  color:#333;
}
.left-item.active{
  background:#fff;
  color:#ff5f00;
  font-weight:bold;
}

/* 右侧 */
.right{
  flex:1;
  background:#fff;
}
.section-title{
  padding:20rpx;
  background:#f5f5f5;
  font-size:30rpx;
  color:#333;
}
.goods-item{
  display:flex;
  padding:20rpx;
  border-bottom:1rpx solid #eee;
}
.pic{
  width:120rpx;
  height:120rpx;
  margin-right:20rpx;
  border-radius:8rpx;
}
.info{
  display:flex;
  flex-direction:column;
  justify-content:center;
}
.name{
  font-size:30rpx;
  color:#333;
}
.price{
  font-size:28rpx;
  color:#ff5f00;
  margin-top:10rpx;
}

组件的js文件

js
// components/testDemo/testDemo.js
let timer = null; // 用于存储节流定时器
let flag = true; // 节流阀
let scrollTop = 0;
let timer2 = null;
Component({
  /**
   * 组件的属性列表
   */
  properties: {
    categoryList: {
      type: Array,
      value: []
    } // 数据列表
  },

  /**
   * 组件的初始数据
   */
  data: {
    currentIndex: 0,
    rightScrollTop: 0,
    leftScrollTop: 0,
    heightArr: [], // 右侧各分类绝对高度
    lockScroll: false, // 点击左侧时禁止右侧滚动事件
    jumpNum: 3, // 点击左侧按钮,让目标标签滚动到距离上方第几个格子,0代表第0个格子,也就是直接滚动到最上方
  },
  lifetimes: {
    ready() {
      console.log(JSON.stringify(this.data.categoryList))
      // 这个高度可以做个回调函数,每次点击左侧或者滑动右侧都计算一下高度,返回了数据之后在执行后面的,因为我这里数据都是死数据,所以没有动态获取,理论是动态获取的
      this.calcHeight()
    }
  },
  /**
   * 组件的方法列表
   */
  methods: {
    /* 点击左侧分类:右侧跳到对应分类顶部,左侧滚到可视中间 */
    selectCategory(e) {
      const index = e.currentTarget.dataset.index;

      /* 1. 右侧滚到分类顶部 */
    this.setData({
      currentIndex: index,
      lockScroll: true
    });

    // 左侧跟随滚动
    this.scrollLeft(index)

    await this.sleep(300)
    this.setData({
      rightScrollTop: this.data.heightArr[this.data.currentIndex],
    })
      await this.sleep(1000)
        this.setData({
          lockScroll:false
        })
    },
    scrollLeft(index) {
      wx.createSelectorQuery()
        .in(this)
        .select('.left')
        .scrollOffset(res => {
          const currentScrollTop = res.scrollTop;
          wx.createSelectorQuery()
            .in(this)
            .select(`.left-${index}`) // 关键:这里改成真正的 class
            .boundingClientRect(rect => {
              if (!rect) return;
              const h = rect.height; // 单行真实高度
              const need = currentScrollTop + rect.top - this.data.jumpNum * h; // 向上多留两个
              this.setData({
                leftScrollTop: Math.max(0, need)
              });
            })
            .exec();
        })
        .exec();
    },
    // 防抖函数
    sleep(ms) {
      clearTimeout(timer);
      timer = null
      return new Promise((resolve, reject) => {
       timer = setTimeout(() => {
          resolve();
        }, ms);
      });
     },
    /* 右侧滚动时联动左侧高亮(带锁)*/
    async onScroll(e) {
      if(this.data.lockScroll) return
      scrollTop = e.detail.scrollTop;
      await this.sleep(800)
      const arr = this.data.heightArr;
      let current = 0;
      for (let i = 0; i < arr.length; i++) {
        if (scrollTop >= arr[i] - 50) current = i;
        else break;
      }
      if (current !== this.data.currentIndex) {
        this.setData({
          currentIndex: current
        });
        /* 让左侧跟着滚动 */
        this.scrollLeft(current)
      }
    },

    /* 计算右侧每个分类到顶部绝对高度 */
    calcHeight() {
      const query = wx.createSelectorQuery().in(this);
      let acc = 0;
      let arr = [0];
      query.selectAll('.section').boundingClientRect(rects => {
        rects.forEach(item => {
          acc += item.height;
          arr.push(acc);
        });
        this.setData({
          heightArr: arr
        });
      }).exec();
    },
  }
})

页面使用相关

页面的json文件

json
{
  "usingComponents": {
    "test_demo":"/components/testDemo/testDemo"
  }
}

页面的wxml文件

html
<test_demo categoryList="{{categoryList}}"></test_demo>

页面的js文件

js
Page({
  data: {
    categoryList: [],
  },

  onLoad() {
    this.setData({
      categoryList: this.mockData()
    });
  },

  /* 30 个分类 Demo */
  mockData() {
    const names = Array.from({
      length: 30
    }, (_, i) => `分类${i + 1}`);
    return names.map((n, idx) => ({
      id: idx + 1,
      name: n,
      goods: Array.from({
        length: 6
      }).map((_, i) => ({
        id: `${idx}-${i}`,
        name: `${n}-商品${i + 1}`,
        price: (Math.random() * 30 + 5).toFixed(2)
      }))
    }));
  }
});

无css文件