微信小程序-实现类似美团外卖店铺页面滑动左右联动效果

代码案例:微信小程序-实现类似美团外卖店铺页面滑动左右联动效果(代码案例)

思考

首先看看简易效果图:

简易效果图

通过scroll-view来实现,要求是点击menu,滚动到锚点;滚动到锚点,激活相应的menu。

思考:
1.思考页面布局
2.思考可能使用到的技术点

技术点:
1.点击滚动到锚点位置:可以通过 scroll-viewscroll-into-view="" 属性来实现。scroll-view
2.滚动到锚点,激活相应的menu:滚动list的时候,记录 scroll-view 的 scrollTop;获取相关wxml元素的高度,根据高度算出来每个锚点的scrollTop;menu根据 scrollTop 是否滚动到相应锚点,去确定决定是否激活。
3.吸顶效果采用:position: sticky;
4.页面滚动到后面时,左侧相应的menu可能会被隐藏,因此我们需要在左侧menu变为下面时,把左侧的向上滚动。

关于滚动与menu的上层结构设计大概如下:

<view class="sidebar-scroll-view custom-class">
  <!-- left -->
  <scroll-view class="scroller-left scroller-left-class" id="scroller-left" scroll-y="{{ true }}" scroll-top="{{ scrollLeftTop }}">
  </scroll-view>
  <!-- right -->
  <scroll-view class="scroller-right scroller-right-class" id="scroller-right" scroll-y="{{ true }}" scroll-with-animation="{{ true }}" scroll-into-view="{{ scrollLocationId }}" bindscroll="onScrollRightEvent">
  </scroll-view>
</view>

关于文件目录如下:

文件目录

代码:

index.html

<!--pages/index/index.wxml-->
<view class="index">
  <!-- header -->
  <view class="index-header">
    <view class="search">
      <input class="search-input" type="text" placeholder="搜索 商品名称" />
    </view>
  </view>
  <!-- body -->
  <view class="index-body">
    <sidebar-scroll-view wx:if="{{ list.length !== 0 }}" custom-class="sidebar-scroll-view" list="{{ list }}"></sidebar-scroll-view>
  </view>
  <!-- footer -->
  <view class="index-footer">
    <!-- goods-action -->
    <view class="goods-action">
      <view class="goods-action-icon">
        <image class="goods-action-icon__icon" src="https://wximg.bdsimg.com/thxx_v2/wxapp/images/icon-home-gray.png"></image>
        首页
      </view>
      <view class="goods-action-icon">
        <image class="goods-action-icon__icon" src="https://wximg.bdsimg.com/thxx_v2/wxapp/images/icon-home-gray.png"></image>
        购物车
      </view>
      <view class="goods-action-btns">
        <button class="goods-action-button goods-action-button--first goods-action-button--both" style="background:linear-gradient(to right, #F6C644, #FF8600);" loading="{{ addcartLoading }}">
          <text class="goods-action-button__text">加入购物车</text>
        </button>
        <button class="goods-action-button goods-action-button--first goods-action-button--both" style="background:linear-gradient(to right, #FF6300, #FF5030);" loading="{{ addcartLoading }}">
          <text class="goods-action-button__text">立即购买</text>
        </button>
      </view>
    </view>
  </view>
</view>

index.wxss

/* pages/index/index.wxss */

.index {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
}

/* header */

.index-header {
  display: flex;
  flex-direction: row;
  align-items: center;
  padding: 24rpx;
  height: 112rpx;
  box-sizing: border-box;
}

.search {
  position: relative;
  flex: 1;
  padding: 12rpx 80rpx;
  border-radius: 32rpx;
  background-color: #F5F5F5;
}

.search-input {
  line-height: 40rpx;
  font-size: 26rpx;
  color: #CCCCCC;
  font-weight: 400;
}

/* body */

.index-body {
  flex: 1;
  display: flex;
  flex-direction: row;
  height: calc(100vh - 112rpx - 240rpx);
  width: 100vw;
}

/* footer */

.index-footer {
  height: 100rpx;
  border-top: 1px solid #F5F5F5;
}

/* goods-action */

.goods-action {
  position: fixed;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 100;
  padding-bottom: env(safe-area-inset-bottom);
  display: -webkit-box;
  display: -webkit-flex;
  display: flex;
  -webkit-box-align: center;
  -webkit-align-items: center;
  align-items: center;
  box-sizing: content-box;
  height: 100rpx;
  background-color: #fff;
  border-top: 2rpx solid #ececec;
}

.goods-action-icon {
  position: relative;
  display: flex;
  -webkit-box-orient: vertical;
  -webkit-box-direction: normal;
  flex-direction: column;
  -webkit-box-pack: center;
  justify-content: center;
  min-width: 118rpx;
  height: 100%;
  color: #828382;
  font-size: 24rpx;
  line-height: 1;
  text-align: center;
  background-color: #fff;
  cursor: pointer;
}

.goods-action-icon__icon {
  display: block;
  width: 44rpx;
  height: 44rpx;
  font-size: 44rpx;
  line-height: 44rpx;
  margin: 0 auto 10rpx;
  color: #828382;
}

.goods-action-icon__icon-dot {
  position: absolute;
  top: 0;
  right: 0;
  box-sizing: border-box;
  min-width: 32rpx;
  padding: 0 6rpx;
  color: #fff;
  font-weight: 500;
  font-size: 24rpx;
  line-height: 28rpx;
  text-align: center;
  background-color: #ee0a24;
  border: 1px solid #fff;
  border-radius: 28rpx;
}

.goods-action-btns {
  -webkit-box-flex: 1;
  -webkit-flex: 1;
  flex: 1;
  display: flex;
  -webkit-box-align: center;
  -webkit-align-items: center;
  align-items: center;
  justify-content: center;
}

.goods-action-button {
  -webkit-box-flex: 1;
  -webkit-flex: 1;
  flex: 1;
  height: 68rpx;
  line-height: 68rpx;
  font-weight: 500;
  font-size: 28rpx;
  border: none;
  box-sizing: border-box;
  width: 100% !important;
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
  border-radius: 68rpx;
  background-color: ;
}

.goods-action-button--both {
  min-width: 195rpx !important;
}

.goods-action-button--one {
  min-width: 385rpx !important;
  margin: 0 !important;
}

.goods-action-button .goods-action-button__text {
  font-size: 28rpx;
  font-weight: 400;
  color: #fff;
}

.goods-action-button--last {
  margin-right: 5px;
  border-top-right-radius: 999px;
  border-bottom-right-radius: 999px;
}

.goods-action-button--first.van-button {
  margin-left: 5px;
  border-top-left-radius: 999px;
  border-bottom-left-radius: 999px;
}

index.js

// pages/index/index.js

import dataJson from '../../data/data'

Page({
  /**
   * 页面的初始数据
   */
  data: {
    list: [],
    submitLoading: false
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad: function (options) {
    console.log(dataJson.result)
    setTimeout(() => {
      this.setData({
        list: dataJson.result
      })
    }, 1500)
  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow: function () {

  }
})

index.json

{
  "usingComponents": {
    "sidebar-scroll-view": "./components/sidebar-scroll-view/sidebar-scroll-view"
  }
}

components/sidebar-scroll-view/sidebar-scroll-view.wxml

<!--pages/index/components/sidebar-scroll-view/sidebar-scroll-view.wxml-->
<view class="sidebar-scroll-view custom-class">
  <!-- left: bindscroll="onScrollLeftEvent" -->
  <scroll-view class="scroller-left scroller-left-class" id="scroller-left" scroll-y="{{ true }}" scroll-top="{{ scrollLeftTop }}">
    <!-- sidebar-menu -->
    <view class="sidebar-menu">
      <view
        class="sidebar-item {{ scrollRightTop >= scrollTopList[index] && scrollRightTop < scrollTopList[index + 1] ? 'sidebar-item--selected' : '' }}"
        hover-class="sidebar-item--hover"
        hover-stay-time="70"
        wx:for="{{ list }}"
        wx:key="index"
        data-index="{{ index }}"
        bind:tap="handleSidebarChange"
      >
        <view>{{ item.categoryName }}</view>
        <view style="color:;font-size:;font-weight:400;">{{ item.confirmedNum }}/{{ item.skuNum }}</view>
      </view>
    </view>
  </scroll-view>
  <!-- right -->
  <scroll-view class="scroller-right scroller-right-class" id="scroller-right" scroll-y="{{ true }}" scroll-with-animation="{{ true }}" scroll-into-view="{{ scrollLocationId }}" bindscroll="onScrollRightEvent">
    <!-- product -->
    <view class="product-body">
      <!-- chunk -->
      <view class="product-body__chunk" wx:for="{{ list }}" wx:key="index">
        <!-- chunk-title -->
        <view class="product-chunk__title">{{ item.categoryName }}</view>
        <!-- 锚点定位: 必须这样子 -->
        <view class="anchor-point" id="anchor-point-title-{{ index }}"></view>
        <!-- product-list -->
        <view class="product-list" id="product-chunk-list-{{ index }}">
          <view class="product-list__item" wx:for="{{ item.productList }}" wx:for-index="idx" wx:for-item="goodsItem" wx:key="idx">
            <image
              class="product-list__item-pic"
              src="https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpuui.qpic.cn%2Fvshpic%2F0%2FmgJtAXymTWfJSZbCFzCX8ROr6wHQQoUWGvQ28eO-8qcA42WP_0%2F0.jpg&refer=http%3A%2F%2Fpuui.qpic.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1611556396&t=b6f7bfcc5c0f80bc0680be5183dd734e"
            >
            </image>
            <view class="product-list__item-title">{{ goodsItem.productName }}</view>
          </view>
        </view>
      </view>
    </view>
  </scroll-view>
</view>

components/sidebar-scroll-view/sidebar-scroll-view.wxss

/* pages/index/components/sidebar-scroll-view/sidebar-scroll-view.wxss */

.sidebar-scroll-view {
  display: flex;
  flex-direction: row;
  width: 100vw;
  height: 100%;
}

/* left */

.scroller-left {
  width: 168rpx;
  padding: 0;
  margin: 0;
}

/* right */

.scroller-right {
  display: flex;
  width: calc(100vw - 168rpx);
  flex: 1;
  padding: 0;
  margin: 0;
  overflow: hidden;
}

/* sidebar-menu */

.sidebar-menu {
  width: 168rpx;
}

.sidebar-item {
  display: block;
  box-sizing: border-box;
  overflow: hidden;
  border-left: 3px solid transparent;
  user-select: none;
  padding: 28rpx 24rpx;
  font-size: 28rpx;
  line-height: 38rpx;
  color: #666;
  background-color: #F5F5F5;
}

.sidebar-item--hover:not(.sidebar-item--disabled) {
  background-color: #f2f3f5;
}

.sidebar-item--selected {
  color: #333333;
  font-weight: 600;
}

.sidebar-item--selected,
.sidebar-item--selected.sidebar-item--hover {
  background-color: #fff;
}

.sidebar-item--disabled {
  color: #c8c9cc;
}

/* product */

.product-body {
  flex: 1;
  width: 100%;
}

/* chunk */

.product-body__chunk {
}

/* chunk-title */

.product-chunk__title {
  position: -webkit-sticky;
  position: sticky;
  top: 0;
  z-index: 100;
  line-height: 52rpx;
  font-size: 26rpx;
  color: #333333;
  font-weight: 400;
  padding: 0 14rpx;
  background-color: #FFFFFF;
}

/* product-list */

.product-list {}


.product-list__item {
  position: relative;
  height: 196rpx;
  width: 100%;
  box-sizing: border-box;
  padding: 16rpx;
  padding-left: 192rpx;
}

.product-list__item-pic {
  position: absolute;
  top: 16rpx;
  left: 16rpx;
  width: 160rpx;
  height: 160rpx;
  border-radius: 15rpx;
  background-color: #D8D8D8;
}

.product-list__item-title {
  line-height: 32rpx;
  font-size: 28rpx;
  color: #333333;
  font-weight: 500;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.anchor-point {
  width: 100%;
  height: 0;
  margin: 0;
  padding: 0;
}

components/sidebar-scroll-view/sidebar-scroll-view.json

{
  "component": true,
  "usingComponents": {}
}

components/sidebar-scroll-view/sidebar-scroll-view.js

// pages/index/components/sidebar-scroll-view/sidebar-scroll-view.js

import debounce from "../../../../utils/debounce.js"

let oldActiveKey = 0

Component({
  /**
   * 组件接受的外部样式类
   */
  externalClasses: ['custom-class'],
  /**
   * 组件的属性列表
   */
  properties: {
    list: {
      type: Array,
      value: []
    },
  },

  /**
   * 组件的初始数据
   */
  data: {
    scrollLeftHeight: 0, // scroller-left 的高
    activeKey: 0, // sidebar activeKey
    scrollLeftTop: 0, // 分类 scrollTop
    // 锚点定位 与 滚动定位相应 sidebar-menu
    scrollLocationId: null, // 滚动定位Id
    scrollRightTop: 0, // 商品 scrollTop
    productChunkRects: [], // class product-body__chunk 的 rects
    scrollTopList: [], // class product-body__chunk 的每个开始的 scrollTop
  },

  lifetimes: {
    // 在组件实例进入页面节点树时执行
    attached: function () {},
    // 在组件在视图层布局完成后执行
    ready: function () {
      this.getProductChunkRectsAndScrollTop() // 获取产品块的rects
      // 获取 scroller-left 的高
      wx.createSelectorQuery().in(this).select('#scroller-left').boundingClientRect((rect) => {
        console.log('scroller-left', rect)
        this.setData({
          scrollLeftHeight: rect.height
        })
      }).exec()
    }
  },

  /**
   * 组件的方法列表
   */
  methods: {
    // scroll --------------------------------------------------------
    // 商品 scroll-view 滚动事件
    onScrollRightEvent(event) {
      // console.log('onScrollEvent', event, event.detail)
      const {
        scrollTop
      } = event.detail
      this.setData({
        scrollRightTop: scrollTop
      })
      debounce(500, () => {
        this.computeCurrSidebarActiveByScrollTop(scrollTop)
      })()
    },
    // scroll --------------------------------------------------------
    // 锚点定位 --------------------------------------------------------
    // 切换 sidebar
    handleSidebarChange(event) {
      // console.log('handleSidebarChange', event)
      let activeKey = event.currentTarget.dataset.index
      this.setData({
        activeKey
      })
      // 锚点定位
      this.gotoAnchorPointLocation(`anchor-point-title-${activeKey}`)
    },
    // 锚点定位与scrollTop
    gotoAnchorPointLocation(scrollLocationId) {
      // console.log('gotoAnchorPointLocation', scrollLocationId)
      this.setData({
        scrollLocationId
      })
    },
    // 锚点定位 --------------------------------------------------------
    // 滚动定位相应 sidebar-menu ----------------------------------------
    // 获取产品块的rects
    getProductChunkRectsAndScrollTop() {
      wx.showLoading({
        title: '加载中...',
        mask: true
      })
      wx.createSelectorQuery().in(this).selectAll('.product-body__chunk').boundingClientRect((rects) => {
        wx.hideLoading()
        let scrollTopList = []
        let acc = rects.map(item => item.height).reduce(function (accumulator, currentValue) {
          scrollTopList.push(accumulator)
          return accumulator + currentValue
        }, 0)
        scrollTopList.push(acc)
        console.log('scrollTopList', scrollTopList)
        // console.log('getProductChunkRectsAndScrollTop', rects)
        this.setData({
          productChunkRects: rects,
          scrollTopList
        })
      }).exec()
    },
    // 通过当前滚动位置计算当前 Sidebar 的 Active
    computeCurrSidebarActiveByScrollTop(scrollTop) {
      if (this.data.productChunkRects.length !== 0) {
        let currScrollTopList = this.data.scrollTopList.filter(item => item <= scrollTop)
        let activeKey = currScrollTopList.length - 1 >= 0 ? currScrollTopList.length - 1 : 0
        // console.log(currScrollTopList, scrollTop, activeKey, oldActiveKey)
        // 不直接复制通过 this.setData({ activeKey }),为了加快渲染速度
        if (oldActiveKey !== activeKey) {
          oldActiveKey = activeKey
          this.setData({
            activeKey
          }, () => {
            // 动态显示 Sidebar 的 activeKey 处在一个合理的位置
            let scrollLeftTop = Math.floor((activeKey + 1) * 70 / this.data.scrollLeftHeight) * this.data.scrollLeftHeight - 70
            // console.log(scrollLeftTop, this.data.scrollLeftHeight)
            this.setData({
              scrollLeftTop: scrollLeftTop > 0 ? scrollLeftTop : 0
            })
          })
        }
        // console.log('----', currScrollTopList, activeKey, oldActiveKey)
      } else {
        this.getProductChunkRectsAndScrollTop()
      }
    },
    // 锚点定位 ----------------------------------------
  }
})

推荐阅读更多精彩内容