[HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(下)

2025-06-06 22:47:52
104次阅读
0个评论

[HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(下)

项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star

效果演示

img_9264bad3.png

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

登录 后评论。没有帐号? 注册 一个。