手写类似于BetterScroll样式的左右联动菜单 uni-app+vue3+ts (使用了script setup语法糖)

发布时间 2023-12-04 16:07:20作者: 反应弧有点长

 注意:在模拟器用鼠标滚动是不会切换光标的,因为使用的是触摸滑动。【自定义类型贴在最后了】

script 部分如下:

import { onMounted } from 'vue'
import type { orderDetail } from '@/types/category'
import type { mainArr } from '@/types/main-arr'
import { nextTick, ref } from 'vue'
import { getCurrentInstance } from 'vue'

//页面加载
onMounted(async () => {
  await getListData()
})

//#region 左右联动菜单
const instance = getCurrentInstance()
//分类列表数据--可以多写几个
const categoryList = [
  {
    id: '1',
    name: '即食',
    picture: 'el-icon-chicken',
    children: [
      {
        deveicId: 1,
        memo: '泸州老窖特曲浓香型白酒',
        discount: 100,
        id: 2,
        inventory: 3,
        goodsName: '草莓',
        orderNum: 1,
        goodsPicPath: '/static/images/locate.png',
        price: 8.0,
        orderMoney: 0,
        oldPrice: 0,
        isLimitPromotion: false,
      },
    ],
  },
]

const mainArray = ref<mainArr>([]) //右侧显示内容(标题+文本)
const topArr = ref<any[]>([]) //每个锚点与到顶部距离
const leftIndex = ref(0) //左边光标index
const isMainScroll = ref<boolean>(false) // 是否touch到右侧
const scrollInto = ref('') //锚点

/* 获取列表数据 */
const getListData = async () => {
  const left = ref<string[]>([])
  const main = ref<mainArr>([])

  categoryList.forEach((item) => {
    left.value.push(`${item.id + 1}类商品`)

    let list: orderDetail[] = []
    // for (let i = 0; i < 10; i++)
    item.children.forEach((itm) => {
      list.push(itm)
    })
    main.value.push({
      title: item.name,
      list,
    })
  })
  mainArray.value = main.value
  await nextTick(() => {
    setTimeout(() => {
      getElementTop()
    }, 10)
  })
}

//获取距离顶部的高度
const getScrollTop = (selector: string) => {
  const top = new Promise((resolve, reject) => {
    let query = uni.createSelectorQuery().in(instance)
    query
      .select(selector)
      .boundingClientRect((data: any) => {
        resolve(data.top)
      })
      .exec()
  })
  return top
}

/* 获取元素顶部信息 */
const getElementTop = async () => {
  /* Promise 对象数组 */
  let p_arr: number[] = []
  /* 遍历数据,创建相应的 Promise 数组数据 */
  for (let i = 0; i < mainArray.value.length; i++) {
    const resu = await getScrollTop(`#item-${i}`)
    p_arr.push(Number(resu) - 200)
  }
  /* 主区域滚动容器的顶部距离 */
  getScrollTop('#scroll-el').then((res: any) => {
    let top = res
    // #ifdef H5
    top += 43 //因固定提示块的需求,H5的默认标题栏是44px
    // #endif

    /* 所有节点信息返回后调用该方法 */
    Promise.all(p_arr).then((data) => {
      topArr.value = data
    })
  })
}

/* 主区域滚动监听 */
const mainScroll = (e: { detail: { scrollTop: any } }) => {
  if (!isMainScroll.value) {
    return
  }
  let top = e.detail.scrollTop
  let index = -1
  if (top >= topArr.value[topArr.value.length - 1]) {
    index = topArr.value.length - 1
  } else {
    index = topArr.value.findIndex((item: any, index: number) => {
      return topArr.value[index + 1] >= top
    })
  }
  leftIndex.value = index < 0 ? 0 : index
}
/* 主区域触摸 */
const mainTouch = () => {
  isMainScroll.value = true
}
/* 左侧导航点击 */
const leftTap = (e: any) => {
  let index = e.currentTarget.dataset.index
  isMainScroll.value = false
  leftIndex.value = Number(index)
  scrollInto.value = `item-${index}`
}
//#endregion

 template部分如下:

<view class="content" >
    <view class="list_box">
      <!-- 菜单左边 -->
      <view class="left">
        <scroll-view scroll-y class="scroll">
          <view
            class="item"
            v-for="(item, index) in categoryList"
            :key="index"
            :class="{ active: index == leftIndex }"
            :data-index="index"
            @tap="leftTap($event)"
          >
            {{ item.name }}
          </view>
        </scroll-view>
      </view>
      <view class="main">
        <scroll-view
          scroll-y
          @scroll="mainScroll"
          class="scroll"
          :scroll-into-view="scrollInto"
          :scroll-with-animation="true"
          @touchstart="mainTouch"
          id="scroll-el"
          enhanced
          :show-scrollbar="false"
        >
          <view v-for="(item, index) in mainArray" class="item-first-box" :key="index">
            <view :id="'item-' + index">
              <text class="item-first-title">{{ item.title }}</text>
              <view class="item-first-content" v-for="(goods, index2) in item.list" :key="index2">
                <view class="goods-image-box">
                  <image
                    :src="goods.goodsPicPath"
                    mode="aspectFill"
                    class="goods-image"
                  />
                </view>
                <view class="meta">
                  <view>
                    <view class="name ellipsis">{{ goods.goodsName }}</view>
                    <view class="memo">{{ goods.memo }}</view>
                    <view class="activity-tips" v-if="goods.isLimitPromotion">限时优惠</view>
                  </view>
                  <view class="price">
                    <view>
                      <view class="actual">
                        <text class="symbol">¥</text>
                        <text>{{ goods.price.toFixed(2) }}</text>
                      </view>
                      <view
                        class="oldprice"
                        v-if="goods.oldPrice != 0 && goods.price < goods.oldPrice"
                      >
                        <text class="symbol">¥</text>
                        <text>{{ goods.oldPrice!.toFixed(2) }}</text>
                      </view>
                    </view>
                  </view>
                </view>
              </view>
            </view>
          </view>
          <view style="height: 80%"></view>
        </scroll-view>
      </view>
    </view>
  </view>

scss样式:

page {
  height: 100%;
  overflow: hidden;
  background: #f6f6f6;
}

.content {
  .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;
    height: calc(100vh - 380rpx);

    .left {
      width: 200rpx;
      text-align: center;
      background-color: #f6f6f6;
      line-height: 100rpx;
      box-sizing: border-box;
      font-size: 32rpx;
      color: #666;
      height: 100%;

      .item {
        position: relative;

        &:not(:first-child) {
          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);
          }
        }

        &.active,
        &:active {
          color: #000000;
          background-color: #fff;
        }
      }
    }

    .main {
      height: 100%;
      background-color: #fff;
      padding: 0 20rpx;
      flex-grow: 1;
      box-sizing: border-box;

      .item-first-box {
        position: relative;
        padding-top: 20rpx;
        width: 100%;
      }
      .item-first-title {
        position: relative;
        margin-top: 20rpx;
      }
      .item-first-content {
        position: relative;
        padding-top: 20rpx;
        margin-bottom: 20rpx;
        height: 180rpx;

        .goods-image-box {
          width: 200rpx;
          position: relative;
          float: left;
          z-index: 999;
        }

        .goods-image {
          position: relative;
          width: 170rpx;
          height: 170rpx;
          border-radius: 10rpx;
        }

        .goods-inventory {
          width: 170rpx;
          height: 36rpx;
          border-radius: 0 0 10rpx 10rpx;
          margin-right: 20rpx;
          opacity: 60%;
          background-color: #5c9888;
          position: absolute;
          bottom: 0rpx;
          left: 0;
          font-size: 24rpx;
          color: white;
          text-align: center;
        }

        .goods-inventory-notenough {
          position: absolute;
          width: 170rpx;
          text-align: center;
          font-size: 22rpx;
          bottom: 4rpx;
          left: 0;
          color: white;
        }

        .goods-inventory-zero {
          position: absolute;
          width: 170rpx;
          text-align: center;
          font-size: 22rpx;
          bottom: 4rpx;
          left: 0;
          color: white;
        }
      }
      .meta {
        position: relative;
        display: inline;
      }

      .name {
        height: 40rpx;
        font-size: 26rpx;
        color: #444;
        font-weight: bold;
      }
      .memo {
        display: flex;
        margin-top: 6rpx;
        font-size: 22rpx;
        color: #888;
      }
      .activity-tips {
        display: flex;
        margin-top: 15rpx;
        font-size: 22rpx;
        background-color: #ffd8cb;
        color: #fc6d3f;
        border-radius: 10rpx;
        padding-left: 10rpx;
        padding-right: 10rpx;
        width: 110rpx;
      }
      .type {
        line-height: 1.8;
        padding: 0 15rpx;
        font-size: 24rpx;
        align-self: flex-start;
        border-radius: 4rpx;
        color: #888;
        background-color: #f7f7f8;
      }

      .price {
        display: flex;
        position: relative;
        margin-top: 16rpx;
        font-size: 24rpx;

        .actual {
          color: #444;
          margin-top: 2rpx;
          margin-left: 0rpx;
          float: left;
        }

        .oldprice {
          display: inline-block;
          font-size: 24rpx;
          margin-top: 2rpx;
          color: #999;
          margin-left: 10rpx;
          text-decoration: line-through;
        }
        .symbol {
          font-size: 24rpx;
        }

        .quantity {
          position: absolute;
          top: 0;
          right: 0;
          font-size: 24rpx;
          color: #444;
          z-index: 999999999;
        }
      }

      .right-scroll:last-child {
        border-bottom: 0;
      }
    }

    .scroll {
      height: 100%;
    }
  }
}

 category.d.ts

/** 通用商品类型 */
export type GoodsItem = {
  deveicId?: number
  /** 商品描述 */
  memo: string
  /** 商品折扣 */
  discount: number
  /** id */
  id: number
  /**库存 */
  inventory: number
  /** 商品名称 */
  goodsName: string
  /** 商品已下单数量 */
  orderNum: number
  /** 商品图片 */
  goodsPicPath: string
  /** 商品价格 */
  price: number
  /** 商品原价格 */
  oldPrice?: number
  /**促销id */
  promotionDetialId?: number
  /**是否是限时优惠 */
  isLimitPromotion: boolean
  orderMoney:number
  oldPrice:number
}

main-arr.d.ts

export type main = {
  title: string
  list: orderDetail[]
}

export type mainArr = main[]