1、实现效果:
下拉有自定义组件(商品卡片、进步器、侧边栏等)源码
2、左右联动功能
使用scroll-view来做右边的菜单页,title的id动态绑定充当锚点
<scroll-view :scroll-into-view="toView" scroll-with-animation="true" class="main" @scroll="scroll" scroll-y><view class="scroll_main"><view class="" v-for="(item,index) in list" :id="'type' + index"><view :id="'title' + index"><u-divider>{{item.meal_name}}</u-divider></view><card v-for="(item2,indax) in item.goods" :data="item2" @change="cardChange"></card></view></view></scroll-view>
侧边栏组件点击事件,返回分类信息,根据分类的id,定位到scroll-view对应的title
<view class="nav"><left-nav :data="list" :current="current" @change="navChange"></left-nav></view>
function navChange(e) {current.value = egetRightScrollDistance()}
scroll-view属性@scroll用于监听scroll的滚动距离,注意用防抖(我用的是uView里自带的防抖方法),防止nav跳动
获取每个titile距离盒子顶部的距离,用于判断滚动距离是否超出某个分类
onReady(() => {list.value.forEach((item, index) => {uni.createSelectorQuery().select('#title' + index).boundingClientRect(data => {console.log(data);titleH.value.push(data)}).exec()})})
获取“this”:
const {appContext: {app: {config: {globalProperties}}}} = getCurrentInstance()
/* 菜单滚动监听 */function scroll(e) {//防抖globalProperties.$u.debounce(() => {console.log(e.detail.scrollTop);titleH.value.forEach((item, index) => {if ((e.detail.scrollTop + item.height) > item.top) {current.value = index}})scrollH.value = e.detail.scrollTop}, 100)}
3、加入购物车动画
购物车是固定的,我们得给它固定的id以便找到它
<view class="bottom"><view id="left_icon" class="left_icon" ref="cartBtn" @click="showPop = !showPop"><u-icon name="bag" size="80" color="#fff"></u-icon></view><view class="bottom_info"><view>共计:<text style="font-weight: bold;color: #FB3B26;">{{35}}</text>元</view><view>已点:早餐、中餐、晚餐</view></view><view class="submit">确认预订</view></view></view>
定义移动小球的样式,写活它的初始位置
<!-- 小球 --><view class="ball" v-if="showAnimation" :animation="animation":style="{ top: ballTop + 'px', left: ballLeft + 'px' }"></view>
.ball {position: absolute;z-index: 1;width: 40rpx;height: 40rpx;background-color: red;border-radius: 50%;}
写活“+”号的id,以便我们获取实例
<view v-if="id" class="plus" :id="id" @click="addClick"><u-icon :name="plusIcon" size="32" color="#ffffff" :customStyle="iconStyle"></u-icon></view>
用uni.createAnimation()来制作动画,按钮的位置减去购物车的位置等于偏移的位置
/* 动画效果控制 */function addToCart(item) {const btn = '#id_' + item.id;const car = '.left_icon';console.log('#id_' + item.id);uni.createSelectorQuery().select(btn).boundingClientRect().exec((rect) => {const btnRect = rect[0];const left = btnRect.left;const top = btnRect.top;ballTop.value = top;ballLeft.value = left;uni.createSelectorQuery().select(car).boundingClientRect().exec((rect) => {console.log(rect);const carRect = rect[0];const x = carRect.left;const y = carRect.top;carTop.value = carRect.top;carLeft.value = carRect.left;animationData.value = uni.createAnimation()animationData.value.translate(x - left + 20, y - top).step({duration: 300,})animationTimeout.valueclearTimeout(animationTimeout.value)animation.value = animationData.value.export()showAnimation.value = true;animationTimeout.value = setTimeout(() => {showAnimation.value = false;}, 300);});});}
4、代码
页面booking.vue
<template><view class="booking"><view class="content"><view class="nav"><left-nav :data="list" :current="current" @change="navChange"></left-nav></view><scroll-view :scroll-into-view="toView" scroll-with-animation="true" class="main" @scroll="scroll" scroll-y><view class="scroll_main"><view class="" v-for="(item,index) in list" :id="'type' + index"><view :id="'title' + index"><u-divider>{{item.meal_name}}</u-divider></view><card v-for="(item2,indax) in item.goods" :data="item2" @change="cardChange"></card></view></view></scroll-view></view><view class="bottom"><view id="left_icon" class="left_icon" ref="cartBtn" @click="showPop = !showPop"><u-icon name="bag" size="80" color="#fff"></u-icon></view><view class="bottom_info"><view>共计:<text style="font-weight: bold;color: #FB3B26;">{{35}}</text>元</view><view>已点:早餐、中餐、晚餐</view></view><view class="submit">确认预订</view></view></view><!-- 弹出层 --><u-popup v-model="showPop" mode="bottom" border-radius="20" closeable z-index="1"><scroll-view class="pop_main" scroll-y><view class="pop_title">已选菜品</view><view class="scroll_main"><view class="" v-for="(item,index) in list" :id="'type' + index"><view :id="'title' + index"><u-divider>{{item.meal_name}}</u-divider></view><card v-for="(item2,indax) in item.goods" :data="item2" @change="cardChange" :isAdd="false"></card></view></view></scroll-view></u-popup><!-- 小球 --><view class="ball" v-if="showAnimation" :animation="animation":style="{ top: ballTop + 'px', left: ballLeft + 'px' }"></view>
</template><script setup>import leftNav from "@/components/booking/nav.vue"import card from "@/components/booking/card.vue"import {mockData} from "../binding/mock.js"import {getCurrentInstance,ref} from "vue";import {onLoad,onReady} from '@dcloudio/uni-app';onLoad(e => {mock.value = mockDatalist.value = mock.value.data.datasconsole.log(list.value);})onReady(() => {list.value.forEach((item, index) => {uni.createSelectorQuery().select('#title' + index).boundingClientRect(data => {console.log(data);titleH.value.push(data)}).exec()})})const showPop = ref(false)const animationData = ref()const animation = ref()const animationTimeout = ref()const titleH = ref([])const scrollH = ref(0)const toView = ref("")const current = ref(0)const mock = ref()const list = ref([{}])let ballTop = ref(0);let ballLeft = ref(0);let carTop = ref(0);let carLeft = ref(0);const showAnimation = ref(false);const {appContext: {app: {config: {globalProperties}}}} = getCurrentInstance()/* 菜单滚动监听 */function scroll(e) {//防抖globalProperties.$u.debounce(() => {console.log(e.detail.scrollTop);titleH.value.forEach((item, index) => {if ((e.detail.scrollTop + item.height) > item.top) {current.value = index}})scrollH.value = e.detail.scrollTop}, 100)}function cardChange(e) {addToCart(e)}function navChange(e) {current.value = egetRightScrollDistance()}function getRightScrollDistance() {toView.value = "title" + current.value;}/* 动画效果控制 */function addToCart(item) {const btn = '#id_' + item.id;const car = '.left_icon';console.log('#id_' + item.id);uni.createSelectorQuery().select(btn).boundingClientRect().exec((rect) => {const btnRect = rect[0];const left = btnRect.left;const top = btnRect.top;ballTop.value = top;ballLeft.value = left;uni.createSelectorQuery().select(car).boundingClientRect().exec((rect) => {console.log(rect);const carRect = rect[0];const x = carRect.left;const y = carRect.top;carTop.value = carRect.top;carLeft.value = carRect.left;animationData.value = uni.createAnimation()animationData.value.translate(x - left + 20, y - top).step({duration: 300,})animationTimeout.valueclearTimeout(animationTimeout.value)animation.value = animationData.value.export()showAnimation.value = true;animationTimeout.value = setTimeout(() => {showAnimation.value = false;}, 300);});});}
</script><style lang="scss" scoped>page {background-color: #fff;}.content {min-height: 100vh;display: flex;.nav {flex: 1;min-width: 164rpx;background-color: #F6F6F6;}.main {flex: 3.5;height: 100vh;background-color: #fff;.scroll_main {padding-bottom: 150rpx;}}}.bottom {position: absolute;z-index: 2;bottom: 0;width: 750rpx;height: 132rpx;background: #FFFFFF;box-shadow: 0rpx -2rpx 16rpx 2rpx rgba(164, 164, 164, 0.11);border-radius: 0rpx 0rpx 0rpx 0rpx;display: flex;justify-content: space-between;align-items: center;.bottom_info {flex: 1;margin: 0 20rpx;font-size: 26rpx;line-height: 40rpx;&>view:nth-child(2) {font-size: 24rpx;color: #aaa;}}.submit {color: #FFFFFF;padding: 10rpx 20rpx;background-color: #FB3B26;font-size: 26rpx;border-radius: 30rpx;margin-right: 50rpx;}#left_icon {margin-top: -30rpx;margin-left: 40rpx;width: 120rpx;height: 120rpx;background: #FB3B26;border-radius: 40rpx;line-height: 150rpx;text-align: center;}}.ball {position: absolute;z-index: 1;width: 40rpx;height: 40rpx;background-color: red;border-radius: 50%;}.pop_main {position: relative;max-height: 60vh;padding-top: 100rpx;padding-bottom: 150rpx;&>.pop_title {text-align: center;width: 100vw;height: 100rpx;font-size: 32rpx;font-weight: bold;position: fixed;top: 0;z-index: 1;background-color: #fff;line-height: 100rpx;text-align: center;}}
</style>
侧边栏组件nav.vue
<template><view class="nav_main"><view v-for="(item,index) in data" :class="{'tool-box':true,'item':true,'item_act':current==index}"@click="change(index)">{{item.meal_name}}</view></view>
</template><script setup>const emit = defineEmits(['change'])const props = defineProps({data: {type: Array,default: () => ([])},current: {type: Number,default: () => (0)},});function change(index) {emit('change', index) // 当前值 + 进步值}
</script><style scoped lang="scss">.nav_main {position: fixed;}.item {width: 164rpx;text-align: center;padding: 30rpx 0;font-size: 26rpx;color: #000000;font-weight: 400;position: relative;}.item_act {background-color: #fff;font-size: 26rpx;font-weight: 700;&::before {content: "";display: inline-block;width: 12rpx;height: 34rpx;background: #FC4E3E;border-radius: 0rpx 30rpx 30rpx 0rpx;position: absolute;left: 0;}}
</style>
商品卡片组件card.vue
<template><view class="card_body"><view class="image"></view><view class="foods_info"><view>{{data.name}}</view><view></view><view><view class="">¥{{data.price}}</view><counter v-if="isAdd" :id="'id_' + data.id" :number="data.number ?? 0" @change-click="change"></counter></view></view></view>
</template><script setup>import counter from "@/components/booking/counter.vue"const emit = defineEmits(['change'])const props = defineProps({data: {type: Object,default: () => ({})},isAdd: {type: Boolean,default: () => true}});function change(e) {let obj = props.dataobj.number = econsole.log(obj);emit('change', obj)}
</script><style scoped lang="scss">.card_body {display: flex;margin: 30rpx 20rpx;.image {width: 180rpx;height: 180rpx;background-color: #a1a1a1;border-radius: 10rpx;margin-right: 20rpx;}.foods_info {display: flex;flex-direction: column;justify-content: space-between;flex: 1;&>view:nth-child(1) {font-weight: 700;font-size: 28rpx;color: #000000;}&>view:nth-child(3) {display: flex;align-items: center;font-weight: 400;font-size: 32rpx;color: #000000;justify-content: space-between;}}}
</style>
进步器组件counter.vue
<template><view class="counter"><u-icon v-if="number>0" :name="reduceIcon" size="60" color="#8E8E8E" @click="reduceClick"></u-icon><input v-if="number>0" type="number" :value="number" @blur="inputBlurEvent" @input="inputChangeEvent":disabled="disabled"><view v-if="id" class="plus" :id="id" @click="addClick"><u-icon :name="plusIcon" size="32" color="#ffffff" :customStyle="iconStyle"></u-icon></view></view>
</template><script setup>import {ref,reactive,computed,nextTick} from "vue";const props = defineProps({id: {type: String,default: ""},disabled: {type: Number,default: false},number: {type: Number,default: 0},maxNumber: {type: Number,default: 99999},minNumber: {type: Number,default: 0},progressValue: {type: Number,default: 1},reduceIcon: {type: String,default: "minus-circle"},plusIcon: {type: String,default: "plus"}})const temp = computed(() => {return props.number})const iconStyle = reactive({fontWeight: 'blod'})const emit = defineEmits(['change-click'])// 加function addClick(ev) {emit('change-click', props.number + props.progressValue) // 当前值 + 进步值}// 减function reduceClick() {if (props.number <= props.minNumber) {console.log("不能继续减少啦 ~");return;}if ((props.number - props.progressValue) < props.minNumber) {console.log("不能继续减少");return;}// 3、执行 减操作emit('change-click', props.number - props.progressValue)}function inputBlurEvent(e) {let number = parseInt(e.detail.value)if (isNaN(number) || number === 0) {emit('change-click', 0)return;}// 条件:输入数不为进步值的倍数,则往前取成倍数值let multipie = Math.ceil(number / props.progressValue) // 获取倍数number = multipie * props.progressValue // 向上获取最近的倍数if (number > props.maxNumber) {number = props.maxNumberemit('change-click', number)} else if (number <= props.minNumber) {emit('change-click', props.minNumber)} else {emit('change-click', number)}}function inputChangeEvent(e) {// 限制输入在最大与最小值之间// 注意:因为都是赋值最大或最小值,所以会出现值复用无法重新渲染页面的情况(第一次能重新渲染,之后的都不渲染):已解决let number = parseInt(e.detail.value)if (isNaN(number) || number === 0) {// 为空为0return}if (number > props.maxNumber) {emit('change-click', props.maxNumber)} else if (number <= props.minNumber) {emit('change-click', props.minNumber)} else {emit('change-click', number)}}
</script><style lang="scss" scoped>.counter {display: flex;align-items: center;&>input {width: 2em;font-size: 28rpx;font-family: Source Han Sans CN-Bold, Source Han Sans CN;font-weight: bold;color: #000000;flex: 1;text-align: center;}.plus {margin: 8rpx;width: 48rpx;height: 48rpx;border-radius: 50%;background: #FF3232;display: flex;justify-content: center;align-items: center;&>image {width: 32rpx;height: 30rpx;margin-right: 5rpx;}}}
</style>
模拟数据mock.js
const mockData = {"code": 200,"msg": "","data": {"datas": [{"meal_id": 5,"meal_name": "早餐","meal_type": 1,"goods": [{"id": 4,"name": "牛奶","price": "3.00","img": ""},{"id": 5,"name": "馒头","price": "2.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/f82315767e959b536f64b0a199f99eb5.png"},{"id": 6,"name": "手抓饼","price": "6.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/9370838db9f50a2e950070995975e3b7.png"}]},{"meal_id": 5,"meal_name": "午餐","meal_type": 1,"goods": [{"id": 7,"name": "牛奶","price": "3.00","img": ""},{"id": 8,"name": "牛奶","price": "3.00","img": ""},{"id": 9,"name": "牛奶","price": "3.00","img": ""},{"id": 10,"name": "牛奶","price": "3.00","img": ""},{"id": 11,"name": "牛奶","price": "3.00","img": ""},{"id": 12,"name": "牛奶","price": "3.00","img": ""},{"id": 13,"name": "牛奶","price": "3.00","img": ""},{"id": 14,"name": "牛奶","price": "3.00","img": ""},{"id": 15,"name": "牛奶","price": "3.00","img": ""},{"id": 16,"name": "牛奶","price": "3.00","img": ""},{"id": 17,"name": "牛奶","price": "3.00","img": ""},{"id": 18,"name": "牛奶","price": "3.00","img": ""},{"id": 19,"name": "馒头","price": "2.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/f82315767e959b536f64b0a199f99eb5.png"},{"id": 20,"name": "手抓饼","price": "6.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/9370838db9f50a2e950070995975e3b7.png"}]},{"meal_id": 5,"meal_name": "晚餐","meal_type": 1,"goods": [{"id": 21,"name": "牛奶","price": "3.00","img": ""},{"id": 22,"name": "馒头","price": "2.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/f82315767e959b536f64b0a199f99eb5.png"},{"id": 23,"name": "手抓饼","price": "6.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/9370838db9f50a2e950070995975e3b7.png"}]},{"meal_id": 5,"meal_name": "宵夜","meal_type": 1,"goods": [{"id": 24,"name": "牛奶","price": "3.00","img": ""},{"id": 25,"name": "馒头","price": "2.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/f82315767e959b536f64b0a199f99eb5.png"},{"id": 26,"name": "手抓饼","price": "6.00","img": "http://192.168.1.23:9508/campus_pay_resource/goods/9370838db9f50a2e950070995975e3b7.png"}]}],"school_name": "测试学校"}
}export {mockData
}