[HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(下)
2025-06-06 22:47:52
104次阅读
0个评论
[HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(下)
项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star
效果演示
1. 概述
在上一篇教程中,我们学习了如何使用GridRow和GridCol组件实现基本的餐饮菜单网格布局。本篇教程将在此基础上,深入探讨如何优化布局、添加交互功能,以及实现更多高级特性,打造一个功能完善的餐饮菜单应用。
本教程将涵盖以下内容:
- 菜品详情页的实现
- 购物车功能的完善
- 菜品筛选和排序功能
- 菜品推荐和组合套餐
- 高级动效和交互优化
2. 菜品详情页实现
2.1 详情页布局设计
当用户点击菜品卡片时,我们需要展示菜品的详细信息。下面是菜品详情页的实现:
@State showFoodDetail: boolean = false; // 是否显示菜品详情
@State currentFood: FoodItem | null = null; // 当前查看的菜品
// 在FoodCard方法中添加点击事件
Column() {
// 菜品卡片内容
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({
radius: 6,
color: '#1A000000',
offsetX: 0,
offsetY: 2
})
.onClick(() => {
this.currentFood = item;
this.showFoodDetail = true;
})
// 在build方法末尾添加菜品详情页
if (this.showFoodDetail && this.currentFood) {
this.FoodDetailPage()
}
2.2 详情页组件实现
@Builder
private FoodDetailPage() {
Stack() {
Column() {
// 顶部图片区域
Stack() {
Image(this.currentFood.image)
.width('100%')
.height(240)
.objectFit(ImageFit.Cover)
// 返回按钮
Button({ type: ButtonType.Circle }) {
Image($r('app.media.ic_back'))
.width(20)
.height(20)
.fillColor('#333333')
}
.width(36)
.height(36)
.backgroundColor('#FFFFFF')
.position({ x: 16, y: 16 })
.onClick(() => {
this.showFoodDetail = false;
})
// 收藏按钮
Button({ type: ButtonType.Circle }) {
Image($r('app.media.ic_favorite'))
.width(20)
.height(20)
.fillColor('#333333')
}
.width(36)
.height(36)
.backgroundColor('#FFFFFF')
.position({ x: '90%', y: 16 })
// 标签
if (this.currentFood.tags && this.currentFood.tags.length > 0) {
Row() {
ForEach(this.currentFood.tags, (tag: string) => {
Text(tag)
.fontSize(12)
.fontColor(Color.White)
.backgroundColor(tag === '辣' || tag === '特辣' ? '#FF5722' : '#FF9800')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
.margin({ right: 8 })
})
}
.position({ x: 16, y: 200 })
}
}
.width('100%')
.height(240)
// 菜品信息区域
Column() {
// 菜品名称和评分
Row() {
Text(this.currentFood.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Row() {
Image($r('app.media.ic_star'))
.width(16)
.height(16)
.fillColor('#FFB300')
Text(this.currentFood.rating.toString())
.fontSize(16)
.fontColor('#FFB300')
.margin({ left: 4 })
}
}
.width('100%')
.margin({ top: 16, bottom: 8 })
// 价格和销量
Row() {
Row() {
Text(`¥${this.currentFood.price}`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5722')
if (this.currentFood.originalPrice) {
Text(`¥${this.currentFood.originalPrice}`)
.fontSize(14)
.fontColor('#999999')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 8 })
}
}
Blank()
Text(`月售${this.currentFood.sales}`)
.fontSize(14)
.fontColor('#999999')
}
.width('100%')
.margin({ bottom: 16 })
// 菜品描述
Text('菜品介绍')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 8 })
Text(this.currentFood.description)
.fontSize(14)
.fontColor('#666666')
.margin({ bottom: 16 })
// 菜品特点
Text('菜品特点')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 8 })
Row() {
Column() {
Image($r('app.media.ic_taste'))
.width(32)
.height(32)
Text(this.currentFood.isSpicy ? '麻辣' : '清淡')
.fontSize(12)
.fontColor('#666666')
.margin({ top: 4 })
}
.width('25%')
.alignItems(HorizontalAlign.Center)
Column() {
Image($r('app.media.ic_time'))
.width(32)
.height(32)
Text('15-20分钟')
.fontSize(12)
.fontColor('#666666')
.margin({ top: 4 })
}
.width('25%')
.alignItems(HorizontalAlign.Center)
Column() {
Image($r('app.media.ic_portion'))
.width(32)
.height(32)
Text('1-2人份')
.fontSize(12)
.fontColor('#666666')
.margin({ top: 4 })
}
.width('25%')
.alignItems(HorizontalAlign.Center)
Column() {
Image($r('app.media.ic_calorie'))
.width(32)
.height(32)
Text('350大卡')
.fontSize(12)
.fontColor('#666666')
.margin({ top: 4 })
}
.width('25%')
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ bottom: 16 })
// 推荐搭配
Text('推荐搭配')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 8 })
Scroll() {
Row() {
ForEach(this.getRecommendedFoods(), (item: FoodItem) => {
Column() {
Image(item.image)
.width(80)
.height(80)
.borderRadius(8)
.objectFit(ImageFit.Cover)
Text(item.name)
.fontSize(12)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Text(`¥${item.price}`)
.fontSize(12)
.fontColor('#FF5722')
.margin({ top: 2 })
}
.width(80)
.alignItems(HorizontalAlign.Center)
.margin({ right: 12 })
.onClick(() => {
this.currentFood = item;
})
})
}
}
.scrollBar(BarState.Off)
.scrollable(ScrollDirection.Horizontal)
.width('100%')
.height(120)
.margin({ bottom: 16 })
// 用户评价
Row() {
Text('用户评价')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text('查看全部')
.fontSize(14)
.fontColor('#FF5722')
.onClick(() => {
// 查看全部评价
})
}
.width('100%')
.margin({ bottom: 8 })
// 评价列表(简单展示)
Column() {
Row() {
Image($r('app.media.avatar1'))
.width(36)
.height(36)
.borderRadius(18)
Column() {
Row() {
Text('用户1234')
.fontSize(14)
.fontColor('#333333')
Blank()
Row() {
ForEach([1, 2, 3, 4, 5], (item: number) => {
Image($r('app.media.ic_star'))
.width(12)
.height(12)
.fillColor(item <= this.currentFood.rating ? '#FFB300' : '#E0E0E0')
.margin({ right: 2 })
})
}
}
.width('100%')
Text('菜品很美味,分量足,服务也很好,下次还会再来!')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 4 })
Text('2023-06-15')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor('#F9F9F9')
.borderRadius(8)
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
.borderRadius({ topLeft: 16, topRight: 16 })
.margin({ top: -20 })
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
// 底部操作栏
Row() {
// 购物车按钮
Badge({
count: this.getCartItemsCount(),
position: BadgePosition.RightTop,
style: { color: '#FFFFFF', fontSize: 12, badgeSize: 16, badgeColor: '#FF5722' }
}) {
Column() {
Image($r('app.media.ic_cart'))
.width(24)
.height(24)
Text('购物车')
.fontSize(12)
.fontColor('#666666')
}
}
.width(60)
.height(56)
.onClick(() => {
this.showCart = true;
})
Blank()
// 加入购物车按钮
Button('加入购物车')
.width(120)
.height(40)
.backgroundColor('#FF9800')
.borderRadius(20)
.fontColor(Color.White)
.onClick(() => {
this.addToCart(this.currentFood.id);
this.showToast('已加入购物车');
})
// 立即购买按钮
Button('立即购买')
.width(120)
.height(40)
.backgroundColor('#FF5722')
.borderRadius(20)
.fontColor(Color.White)
.margin({ left: 12 })
.onClick(() => {
this.addToCart(this.currentFood.id);
this.showCart = true;
})
}
.width('100%')
.height(64)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
.borderWidth({ top: 0.5 })
.borderColor('#E0E0E0')
.position({ x: 0, y: '92%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(100)
// 获取推荐菜品
private getRecommendedFoods(): FoodItem[] {
return this.foodItems.filter(item =>
item.id !== this.currentFood.id &&
(item.isRecommended || item.categoryId === this.currentFood.categoryId)
).slice(0, 5);
}
// 显示提示信息
private showToast(message: string): void {
// 实现提示信息
AlertDialog.show({
message: message,
autoCancel: true,
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -100 },
gridCount: 3,
duration: 2000
});
}
}
3. 购物车功能完善
3.1 购物车弹窗实现
@State showCart: boolean = false; // 是否显示购物车
// 在build方法末尾添加购物车弹窗
if (this.showCart) {
this.CartPanel()
}
@Builder
private CartPanel() {
Stack() {
// 半透明背景
Column()
.width('100%')
.height('100%')
.backgroundColor('#80000000')
.onClick(() => {
this.showCart = false;
})
// 购物车面板
Column() {
// 顶部标题栏
Row() {
Text('购物车')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Button('清空')
.backgroundColor('transparent')
.fontColor('#999999')
.fontSize(14)
.onClick(() => {
this.clearCart();
})
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.borderWidth({ bottom: 0.5 })
.borderColor('#E0E0E0')
// 购物车列表
if (this.getCartItemsCount() > 0) {
List() {
ForEach(this.getCartItems(), (item: CartItem) => {
ListItem() {
Row() {
Image(item.food.image)
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
Column() {
Text(item.food.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.food.description)
.fontSize(12)
.fontColor('#999999')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(`¥${item.food.price}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5722')
Blank()
// 数量控制
Row() {
Button({ type: ButtonType.Circle }) {
Text('-')
.fontSize(16)
.fontColor('#666666')
}
.width(24)
.height(24)
.backgroundColor('#F5F5F5')
.onClick(() => {
this.decreaseCartItem(item.food.id);
})
Text(item.quantity.toString())
.fontSize(14)
.fontColor('#333333')
.margin({ left: 12, right: 12 })
Button({ type: ButtonType.Circle }) {
Text('+')
.fontSize(16)
.fontColor('#666666')
}
.width(24)
.height(24)
.backgroundColor('#F5F5F5')
.onClick(() => {
this.increaseCartItem(item.food.id);
})
}
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ top: 12, bottom: 12 })
}
})
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
} else {
// 空购物车提示
Column() {
Image($r('app.media.ic_empty_cart'))
.width(120)
.height(120)
.margin({ bottom: 16 })
Text('购物车空空如也')
.fontSize(16)
.fontColor('#999999')
Text('去挑选喜欢的美食吧')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
// 底部结算栏
Row() {
Column() {
Text(`合计:¥${this.getCartTotalPrice()}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5722')
Text(`共${this.getCartItemsCount()}件商品`)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Blank()
Button('去结算')
.width(120)
.height(40)
.backgroundColor('#FF5722')
.borderRadius(20)
.fontColor(Color.White)
.enabled(this.getCartItemsCount() > 0)
.opacity(this.getCartItemsCount() > 0 ? 1 : 0.5)
.onClick(() => {
this.showCheckout();
})
}
.width('100%')
.height(64)
.padding({ left: 16, right: 16 })
.borderWidth({ top: 0.5 })
.borderColor('#E0E0E0')
.backgroundColor(Color.White)
}
.width('100%')
.height('60%')
.backgroundColor(Color.White)
.borderRadius({ topLeft: 16, topRight: 16 })
.position({ x: 0, y: '40%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(200)
}
3.2 购物车数据结构和方法
// 购物车项接口
interface CartItem {
food: FoodItem;
quantity: number;
}
// 获取购物车商品列表
private getCartItems(): CartItem[] {
const items: CartItem[] = [];
this.cartItems.forEach((quantity, foodId) => {
const food = this.foodItems.find(item => item.id === foodId);
if (food && quantity > 0) {
items.push({ food, quantity });
}
});
return items;
}
// 获取购物车商品总价
private getCartTotalPrice(): number {
let total = 0;
this.cartItems.forEach((quantity, foodId) => {
const food = this.foodItems.find(item => item.id === foodId);
if (food) {
total += food.price * quantity;
}
});
return total;
}
// 增加购物车商品数量
private increaseCartItem(foodId: string): void {
const count = this.cartItems.get(foodId) || 0;
this.cartItems.set(foodId, count + 1);
}
// 减少购物车商品数量
private decreaseCartItem(foodId: string): void {
const count = this.cartItems.get(foodId) || 0;
if (count > 1) {
this.cartItems.set(foodId, count - 1);
} else {
this.cartItems.delete(foodId);
}
}
// 清空购物车
private clearCart(): void {
this.cartItems.clear();
}
// 显示结算页面
private showCheckout(): void {
// 实现结算页面
this.showCart = false;
AlertDialog.show({
title: '订单提交',
message: `总计:¥${this.getCartTotalPrice()}\n即将提交订单`,
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '确认下单',
action: () => {
this.clearCart();
this.showToast('订单已提交');
}
},
secondaryButton: {
value: '取消',
action: () => {
console.info('取消下单');
}
}
});
}
总结
本案例的布局 可以进行更多的扩展哦
00
- 0回答
- 3粉丝
- 0关注
相关话题
- [HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(上)
- [HarmonyOS NEXT 实战案例四] 天气应用网格布局(下)
- [HarmonyOS NEXT 实战案例七] 健身课程网格布局(下)
- [HarmonyOS NEXT 实战案例九] 旅游景点网格布局(下)
- [HarmonyOS NEXT 实战案例五] 社交应用照片墙网格布局(下)
- [HarmonyOS NEXT 实战案例一] 电商首页商品网格布局(下)
- [HarmonyOS NEXT 实战案例八] 电影票务网格布局(下)
- [HarmonyOS NEXT 实战案例四] 天气应用网格布局(上)
- [HarmonyOS NEXT 实战案例七] 健身课程网格布局(上)
- [HarmonyOS NEXT 实战案例九] 旅游景点网格布局(上)
- [HarmonyOS NEXT 实战案例五] 社交应用照片墙网格布局(上)
- [HarmonyOS NEXT 实战案例一] 电商首页商品网格布局(上)
- [HarmonyOS NEXT 实战案例八] 电影票务网格布局(上)
- [HarmonyOS NEXT 实战案例三] 音乐专辑网格展示(下)
- [HarmonyOS NEXT 实战案例二] 新闻资讯网格列表(下)