1、目标效果
最近喝了不少的咖啡、奶茶,有一个效果我倒是挺好奇怎么实现的:
(1)点击左侧分类菜单,右侧滚动到该分类区域
(2)右侧滑动屏幕,左侧显示当前所处的分类区域
这种功能会出现在商城项目中或者分类数量较多的项目中,专业名称称电梯导航
目标效果:
(1)点击左侧的分类,右侧滑动到指定区域
(2)滚动右侧区域,左边分类显示当前所处的分类区域
2、原理
(1)这要用到原生js关于偏移量和位置相关的api,这些api建立在你的布局是定位的基础上,父亲相对定位,左边分类和右边商品都是绝对定位
(2)左边分类要与右侧商品模块数量一一相等(数量和位置都要对应相等),否则实现不了电梯导航效果
(3)点击左侧分类,右侧跳转到对应模块;这用到了window.scrollTo(水平方向距离,竖直方向距离)
(4)右侧滑动,左侧发生相应的变化,这要用到滚动事件,vue中使用滚动事件需要再onMounted()生命周期注册一下滚动事件
onMounted(() => { window.addEventListener('scroll', handleScroll); })
(5)如何判断滚动到什么程度左侧才显示对应的模块?
- dom.offsetTop:每个dom元素有该属性,表示距离顶部窗口的距离
- document.documentElement.scrollTop:表示页面滚动的距离
- document.documentElement.scrollTop >=dom.offsetTop:显示对应的模块,可以通过遍历商品模块数组,拿到对应的索引,然后设置左边分类对应的dom为激活状态
(6) 出现一个问题:window.scrollTo()将模块滚动至某一位置 与页面滚动事件 发生了冲突,此时可以添加一个互斥变量isLock,等window.scrollTo()滚动结束之后,再放开锁
// 获取选中的dom元素 const typeItemDom = shop.value[val] // 开锁 isLock.value = true // 第一个参数为水平方向,第二个参数为纵轴方向 window.scrollTo(0, typeItemDom.offsetTop) setTimeout(() => { //关锁 isLock.value = false }, 0)
(7)为什么放开锁要在setTimeout里面?根据js事件循环机制,同步任务(主线程代码、new Promise里面的代码)执行速度快于异步任务(setTimeout、setInterval、ajax、promise.then里面的任务),这样才能确保锁是在window.scrollTo() 执行完毕之后才打开的
3、源代码
App.vue
<template> <div> <ClassifyByVue></ClassifyByVue> </div> </template> <script setup> // import ClassifyByJs from './components/ClassifyByJS.vue'; import ClassifyByVue from './components/ClassifyByVue.vue'; </script> <style> * { padding: 0; margin: 0; } </style>
ClassifyByVue.vue
<template> <div class="classify"> <div class="left"> <div class="item" :class="{ active: currentIndex == index }" v-for="(item, index) in types" @click="changeType(index)">{{ item }} </div> </div> <div class="right" @scroll="handleScroll"> <div v-for="(item, index) in shops" :key="index" ref="shop"> <div class="title">{{ item.category }}</div> <div class="item" v-for="(i, j) in item.data" :key="j"> <div class="photo"> <img src="/vite.svg" class="logo" alt="Vite logo" /> </div> <div class="info"> <div class="name">{{ i.name }}</div> <div class="type">{{ i.type }}</div> <div class="desc">{{ i.desc }}</div> <div class="buy">购买</div> </div> </div> </div> </div> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue' let isLock = ref(false) // 分类 let types = ref([ '人气Top', '爆款套餐', '大师咖啡', '小黑杯', '中国茶咖', '生椰家族', '厚乳拿铁', '丝绒拿铁', '生酪拿铁', '经典拿铁', ]) // 商品 let shops = ref([ { category: '人气Top', data: [ { name: '冰吸生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '摸鱼生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '茉莉花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '小甘橘美式', type: '咖啡', desc: '咖啡' }, ] }, { category: '爆款套餐', data: [ { name: '2杯套餐', type: '咖啡', desc: '咖啡' }, { name: '3杯套餐', type: '咖啡', desc: '咖啡' }, { name: '4杯套餐', type: '咖啡', desc: '咖啡' }, { name: '5杯套餐', type: '咖啡', desc: '咖啡' }, { name: '不喝咖啡套餐', type: '咖啡', desc: '咖啡' }, { name: '必喝套餐', type: '咖啡', desc: '咖啡' }, ] }, { category: '大师咖啡', data: [ { name: '美式', type: '咖啡', desc: '咖啡' }, { name: '加浓美式', type: '咖啡', desc: '咖啡' }, { name: '橙C美式', type: '咖啡', desc: '咖啡' }, { name: '澳瑞白', type: '咖啡', desc: '咖啡' }, { name: '卡布奇诺', type: '咖啡', desc: '咖啡' }, { name: '玛奇朵', type: '咖啡', desc: '咖啡' }, ] }, { category: '小黑杯', data: [ { name: '云南小柑橘', type: '咖啡', desc: '咖啡' }, { name: '广东小柑橘', type: '咖啡', desc: '咖啡' }, { name: '广西小柑橘', type: '咖啡', desc: '咖啡' }, { name: '福建小柑橘', type: '咖啡', desc: '咖啡' }, { name: '湖南小柑橘', type: '咖啡', desc: '咖啡' }, { name: '江西小柑橘', type: '咖啡', desc: '咖啡' }, ] }, { category: '中国茶咖', data: [ { name: '碧螺知春拿铁', type: '咖啡', desc: '咖啡' }, { name: '茉莉花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '菊花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '梅花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '兰花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '玫瑰花香拿铁', type: '咖啡', desc: '咖啡' }, ] }, { category: '生椰家族', data: [ { name: '冰吸生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '摸鱼生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '椰云拿铁', type: '咖啡', desc: '咖啡' }, { name: '丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '陨石拿铁', type: '咖啡', desc: '咖啡' }, ] }, { category: '厚乳拿铁', data: [ { name: '厚乳拿铁', type: '咖啡', desc: '咖啡' }, { name: '生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '茉莉花香拿铁', type: '咖啡', desc: '咖啡' }, { name: '椰云拿铁', type: '咖啡', desc: '咖啡' }, { name: '丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '海盐拿铁', type: '咖啡', desc: '咖啡' }, ] }, { category: '丝绒拿铁', data: [ { name: '丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '生椰丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '黑糖丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '椰云丝绒拿铁', type: '咖啡', desc: '咖啡' }, { name: '香草丝绒拿铁', type: '咖啡', desc: '咖啡' } ] }, { category: '生酪拿铁', data: [ { name: '生酪拿铁', type: '咖啡', desc: '咖啡' }, { name: '绿豆拿铁', type: '咖啡', desc: '咖啡' }, { name: '红豆拿铁', type: '咖啡', desc: '咖啡' }, { name: '黑豆拿铁', type: '咖啡', desc: '咖啡' }, { name: '黄豆拿铁', type: '咖啡', desc: '咖啡' } ] }, { category: '经典拿铁', data: [ { name: '拿铁', type: '咖啡', desc: '咖啡' }, { name: '陨石拿铁', type: '咖啡', desc: '咖啡' }, { name: '焦糖拿铁', type: '咖啡', desc: '咖啡' }, { name: '生椰拿铁', type: '咖啡', desc: '咖啡' }, { name: '美式', type: '咖啡', desc: '咖啡' } ] }, ]) // 获取右侧商品的ref实例 let shop = ref(null) // 用来表示当前选中处于激活状态的分类的索引 let currentIndex = ref(0) // 切换类型 const changeType = val => { currentIndex.value = val // 获取选中的dom元素 const typeItemDom = shop.value[val] // 开锁 isLock.value = true // 第一个参数为水平方向,第二个参数为纵轴方向 window.scrollTo(0, typeItemDom.offsetTop) setTimeout(() => { //关锁 isLock.value = false }, 0) } // 监听页面滚动 const handleScroll = () => { // 锁关了滚动事件才有效 if (!isLock.value) { types.value.forEach((item, index) => { // console.dir(shop.value[index]); const shopItemDom = shop.value[index] // 每个模块距离顶部的距离 const offsetTop = shopItemDom.offsetTop // 页面滚动的距离 const scrollTop = document.documentElement.scrollTop if (scrollTop >= offsetTop) { // 给左边分类设置激活的效果 currentIndex.value = index } }) } } onMounted(() => { window.addEventListener('scroll', handleScroll); }) onBeforeUnmount(() => { window.removeEventListener('scroll', handleScroll); }) </script> <style scoped lang="less"> .classify { display: flex; position: relative; .left { display: flex; flex-direction: column; align-items: center; position: fixed; left: 0; top: 0; bottom: 0; width: 92px; overflow-y: scroll; border-right: 1px solid #C1C2C4; .item { display: flex; justify-content: center; align-items: center; width: 67px; height: 29px; font-size: 15px; font-family: PingFang SC-Semibold, PingFang SC; font-weight: 600; color: #333333; } .active { color: #00B1FF; } .item:not(:last-child) { margin-bottom: 25px; } } .right { flex: 1; position: absolute; top: 0; right: 17px; overflow-y: scroll; .title { font-size: 18px; margin-bottom: 5px; } .item { display: flex; justify-content: space-between; margin-bottom: 17px; width: 246px; height: 73px; .photo { width: 58px; height: 58px; img { width: 100%; height: 100%; border-radius: 12px; border: 1px solid gray; } } .info { display: flex; flex-direction: column; position: relative; width: 171px; height: 73px; box-shadow: 0px 1px 0px 0px rgba(221, 221, 221, 1); .name { padding-left: 0; font-size: 17px; font-weight: 600; color: #333333; } .type, .desc { font-size: 14px; font-weight: 400; color: #999999; } .buy { display: flex; align-items: center; justify-content: center; position: absolute; right: 0; top: 17px; width: 67px; height: 29px; background: #E7E8EA; border-radius: 21px; font-size: 15px; font-family: PingFang SC-Semibold, PingFang SC; font-weight: 600; color: #05AFFA; } } } } } </style>