一、项目概述

1.1 项目简介

本项目基于HarmonyOS NEXT(API 20+)、ArkTS及ArkUI声明式框架开发一款轻量化菜谱展示应用。应用主打轻量化、简洁实用的特点,适配鸿蒙原生交互规范,支持菜谱分类筛选、关键词模糊搜索、菜谱详情查看、多页面路由跳转等核心功能,非常适合鸿蒙开发入门开发者学习列表渲染、数据过滤、页面传参、组件布局等核心知识点。

项目摒弃复杂第三方组件,全部使用系统原生组件开发,代码精简易读、可直接运行,适配移动端自适应布局,卡片式UI设计符合主流App视觉风格。

1.2 应用核心场景

  • 新手烹饪学习:用户可查看完整的食材清单、详细烹饪步骤、制作时长与难度

  • 精准筛选菜谱:支持菜系分类筛选、菜名/食材双维度关键词搜索

  • 详情沉浸式查看:独立详情页展示完整菜谱信息,支持页面返回交互

1.3 技术栈说明

  • 系统版本:HarmonyOS NEXT(API 20及以上)

  • 开发语言:ArkTS

  • UI框架:ArkUI 声明式UI

  • 核心技术:页面路由、数据过滤、实时搜索、卡片布局、滚动容器、条件渲染

二、核心技术知识点详解

2.1 鸿蒙页面路由与参数传递

多页面应用的核心基础就是页面路由,鸿蒙通过内置router模块实现页面跳转、参数传递、页面返回等能力。本项目主要实现首页跳转到菜谱详情页,并携带菜谱唯一标识与名称参数。

核心路由方法说明:

  • pushUrl:保留当前页面,跳转至新页面(常用详情页跳转)

  • back:关闭当前页面,返回上一级页面

  • getParams:接收上一个页面传递的参数

  • replaceUrl:替换当前页面,无返回历史记录(适合登录页跳转)

2.2 TextInput实时搜索组件

TextInput是鸿蒙原生单行输入组件,本项目用于实现顶部搜索栏,通过onChange事件监听输入内容,实现实时模糊搜索,无需点击搜索按钮,输入即筛选数据。同时通过圆角、内边距、背景色属性实现现代化搜索框样式。

2.3 Scroll滚动容器组件

Scroll组件为页面提供局部滚动能力,区别于页面全局滚动,可指定固定滚动区域,适配分类标签、长文本内容的滚动需求,有效解决内容溢出屏幕的布局问题。

2.4 多条件数据过滤逻辑

项目核心业务逻辑,整合分类筛选和关键词搜索双条件过滤。支持根据菜系分类精准筛选,同时支持菜名、食材的双向模糊匹配,并且统一大小写匹配规则,避免大小写导致的搜索失效问题。

2.5 数组与字符串高阶处理

基于ArkTS数组方法filter、some、find实现数据筛选与查找,结合字符串toLowerCase、includes方法实现不区分大小写的模糊匹配,是鸿蒙列表类项目的通用核心写法。

2.6 多形式条件渲染

适配不同业务场景的渲染方式:if-else整体布局渲染、三元运算符文本状态渲染、短路运算局部组件渲染,解决空数据、加载状态、异常状态的页面展示问题。

三、项目页面结构设计

3.1 首页结构(Index页面)

顶部标题栏 + 全局搜索框 + 菜系分类标签栏 + 菜谱卡片列表,层级清晰,交互逻辑自上而下,是典型的列表展示类App布局结构。

3.2 详情页结构(RecipeDetail页面)

导航返回栏 + 菜谱封面图 + 基础信息区(分类/难度/时长) + 食材清单区 + 分步烹饪步骤区,完整展示菜谱全量信息。

四、完整项目源码实现

本项目包含两个核心页面:首页(Index.ets)、菜谱详情页(RecipeDetail.ets),附带完整数据模型、业务逻辑、样式布局,可直接新建鸿蒙项目替换代码运行。

4.1 全局数据模型定义(可直接写在页面顶部)

// 菜谱数据模型
interface Recipe {
  id: number;
  name: string;
  category: string;
  ingredients: string[];
  steps: string[];
  cookTime: number;
  difficulty: string;
  image: string;
}

4.2 首页完整代码(Index.ets)

import router from ‘@ohos.router’;

// 菜系分类数组
const CATEGORY_LIST: string[] = [‘全部’, ‘家常菜’, ‘川菜’, ‘粤菜’, ‘湘菜’];

@Entry
@Component
struct Index {
  // 搜索关键词
  @State searchKeyword: string = '';
  // 选中分类
  @State selectedCategory: string = '全部';
  // 菜谱数据源
  @State recipeList: Recipe[] = [];

  // 页面初始化加载数据
  aboutToAppear() {
    this.recipeList = [
      {
        id: 1,
        name: '家常红烧肉',
        category: '家常菜',
        ingredients: ['五花肉500g', '生姜3片', '大葱2根', '八角2个', '桂皮1小块', '冰糖适量', '生抽2勺', '老抽1勺', '料酒1勺'],
        steps: ['五花肉切块,冷水下锅焯水去除血沫', '锅中少油,放入冰糖小火炒出枣红色糖色', '下入五花肉快速翻炒均匀上色', '加入葱姜八角桂皮翻炒出香味', '加入生抽、老抽、料酒调味,倒入热水没过食材', '大火烧开转小火慢炖60分钟,最后大火收汁即可'],
        cookTime: 90,
        difficulty: '中等',
        image: '红烧肉'
      },
      {
        id: 2,
        name: '宫保鸡丁',
        category: '川菜',
        ingredients: ['鸡胸肉300g', '熟花生米50g', '干辣椒5个', '花椒10粒', '葱姜蒜适量', '黄瓜半根', '胡萝卜半根'],
        steps: ['鸡胸肉切丁,加少许盐、料酒、淀粉腌制15分钟', '冷锅少油,小火炸熟花生米捞出备用', '锅底留油,爆香花椒、干辣椒、葱姜蒜', '下入鸡丁大火翻炒至变色熟透', '加入胡萝卜、黄瓜丁翻炒断生', '调入料汁,最后加入花生米翻炒均匀出锅'],
        cookTime: 30,
        difficulty: '简单',
        image: '宫保鸡丁'
      },
      {
        id: 3,
        name: '白切鸡',
        category: '粤菜',
        ingredients: ['三黄鸡1只', '生姜适量', '葱适量', '香菜少许', '生抽', '香油'],
        steps: ['三黄鸡处理干净,冷水下锅,加入葱姜', '大火煮开后转小火浸煮20分钟', '关火焖10分钟后捞出过冰水', '鸡肉切块摆盘,调制生抽香油料汁', '淋上料汁,撒上香菜即可食用'],
        cookTime: 40,
        difficulty: '简单',
        image: '白切鸡'
      },
      {
        id: 4,
        name: '剁椒鱼头',
        category: '湘菜',
        ingredients: ['胖头鱼鱼头1个', '剁椒2勺', '生姜大蒜适量', '葱花少许', '蒸鱼豉油', '食用油'],
        steps: ['鱼头处理干净,对半切开摆盘', '铺上切好的姜片蒜末,铺满剁椒', '水开后上锅大火蒸15分钟', '倒掉盘中多余汁水,淋上蒸鱼豉油', '撒上葱花,浇上热油激发出香味即可'],
        cookTime: 50,
        difficulty: '中等',
        image: '剁椒鱼头'
      }
    ]
  }

  // 多条件过滤菜谱数据
  private filterRecipeData(): Recipe[] {
    let resultList = this.recipeList;
    // 分类筛选
    if (this.selectedCategory !== '全部') {
      resultList = resultList.filter(item => item.category === this.selectedCategory)
    }
    // 关键词模糊搜索(菜名+食材,不区分大小写)
    if (this.searchKeyword.trim() !== '') {
      const key = this.searchKeyword.trim().toLowerCase();
      resultList = resultList.filter(item => {
        const nameMatch = item.name.toLowerCase().includes(key);
        const ingredientMatch = item.ingredients.some(ing => ing.toLowerCase().includes(key));
        return nameMatch || ingredientMatch;
      })
    }
    return resultList;
  }

  // 根据难度获取对应颜色
  private getDifficultyColor(diff: string): string {
    switch (diff) {
      case '简单':
        return '#10b981';
      case '中等':
        return '#f59e0b';
      default:
        return '#ef4444';
    }
  }

  // 跳转菜谱详情页
  private goRecipeDetail(item: Recipe) {
    router.pushUrl({
      url: 'pages/RecipeDetail',
      params: {
        recipeId: item.id,
        recipeName: item.name
      }
    })
  }

  // 分类标签组件
  @Builder CategoryItem(title: string) {
    Text(title)
      .fontSize(14)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(16)
      .backgroundColor(this.selectedCategory === title ? '#2563eb' : '#f1f5f9')
      .fontColor(this.selectedCategory === title ? '#fff' : '#333')
      .onClick(() => {
        this.selectedCategory = title;
      })
  }

  build() {
    Column() {
      // 顶部标题栏
      Text('美食菜谱大全')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .textAlign(TextAlign.Start)
        .padding({ left: 16, top: 20, bottom: 16 })

      // 搜索框
      TextInput({
        placeholder: '搜索菜谱、食材...',
        text: this.searchKeyword
      })
      .width('92%')
      .height(44)
      .borderRadius(22)
      .backgroundColor('#ffffff')
      .padding({ left: 16, right: 16 })
      .shadow({ radius: 4, color: '#00000010' })
      .onChange((val: string) => {
        this.searchKeyword = val;
      })

      // 分类滚动栏
      Scroll() {
        Row() {
          ForEach(CATEGORY_LIST, (item: string) => {
            this.CategoryItem(item)
              .margin({ right: 10 })
          })
        }
        .padding({ left: 16, right: 16, top: 16 })
      }
      .width('100%')
      .scrollable(ScrollDirection.Horizontal)

      // 菜谱列表
      List() {
        const filterList = this.filterRecipeData();
        if (filterList.length === 0) {
          ListItem() {
            Text('暂无匹配菜谱')
              .fontSize(16)
              .fontColor('#999')
              .margin({ top: 100 })
              .width('100%')
              .textAlign(TextAlign.Center)
          }
        } else {
          ForEach(filterList, (item: Recipe) => {
            ListItem() {
              // 菜谱卡片
              Column() {
                // 卡片顶部图片区域
                Column() {
                  Text(item.image)
                    .fontSize(28)
                    .fontColor('#fff')
                }
                .width('100%')
                .height(160)
                .borderRadius({ topLeft: 12, topRight: 12 })
                .backgroundColor('#f97316')
                .justifyContent(FlexAlign.Center)

                // 卡片信息区域
                Column() {
                  Text(item.name)
                    .fontSize(18)
                    .fontWeight(FontWeight.Bold)
                    .width('100%')
                    .textAlign(TextAlign.Start)

                  Row() {
                    Text(item.category)
                      .fontSize(12)
                      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                      .backgroundColor('#f1f5f9')
                      .borderRadius(4)
                      .fontColor('#64748b')

                    Text(item.difficulty)
                      .fontSize(12)
                      .fontColor(this.getDifficultyColor(item.difficulty))
                      .margin({ left: 10 })

                    Text(`${item.cookTime}分钟`)
                      .fontSize(12)
                      .fontColor('#666')
                      .margin({ left: 10 })
                  }
                  .width('100%')
                  .margin({ top: 8 })

                  Text(`${item.ingredients.length}种食材`)
                    .fontSize(13)
                    .fontColor('#999')
                    .width('100%')
                    .margin({ top: 6 })
                }
                .width('100%')
                .padding(12)
              }
              .width('92%')
              .backgroundColor('#fff')
              .borderRadius(12)
              .shadow({ radius: 6, color: '#00000015' })
              .margin({ top: 12 })
              .onClick(() => this.goRecipeDetail(item))
            }
          })
        }
      }
      .layoutWeight(1)
      .width('100%')
      .padding({ bottom: 20 })

    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f8f9fa')
  }
}

4.3 详情页完整代码(RecipeDetail.ets)

import router from ‘@ohos.router’;

@Entry
@Component
struct RecipeDetail {
  @State recipeId: number = 0;
  @State recipeName: string = '';
  // 模拟完整菜谱数据,实际项目可通过id请求/查询数据
  @State recipeDetail: Recipe | null = null;

  // 根据ID匹配菜谱数据
  private getRecipeDetailById(id: number): Recipe | undefined {
    const list: Recipe[] = [
      {
        id: 1,
        name: '家常红烧肉',
        category: '家常菜',
        ingredients: ['五花肉500g', '生姜3片', '大葱2根', '八角2个', '桂皮1小块', '冰糖适量', '生抽2勺', '老抽1勺', '料酒1勺'],
        steps: ['五花肉切块,冷水下锅焯水去除血沫', '锅中少油,放入冰糖小火炒出枣红色糖色', '下入五花肉快速翻炒均匀上色', '加入葱姜八角桂皮翻炒出香味', '加入生抽、老抽、料酒调味,倒入热水没过食材', '大火烧开转小火慢炖60分钟,最后大火收汁即可'],
        cookTime: 90,
        difficulty: '中等',
        image: '红烧肉'
      },
      {
        id: 2,
        name: '宫保鸡丁',
        category: '川菜',
        ingredients: ['鸡胸肉300g', '熟花生米50g', '干辣椒5个', '花椒10粒', '葱姜蒜适量', '黄瓜半根', '胡萝卜半根'],
        steps: ['鸡胸肉切丁,加少许盐、料酒、淀粉腌制15分钟', '冷锅少油,小火炸熟花生米捞出备用', '锅底留油,爆香花椒、干辣椒、葱姜蒜', '下入鸡丁大火翻炒至变色熟透', '加入胡萝卜、黄瓜丁翻炒断生', '调入料汁,最后加入花生米翻炒均匀出锅'],
        cookTime: 30,
        difficulty: '简单',
        image: '宫保鸡丁'
      },
      {
        id: 3,
        name: '白切鸡',
        category: '粤菜',
        ingredients: ['三黄鸡1只', '生姜适量', '葱适量', '香菜少许', '生抽', '香油'],
        steps: ['三黄鸡处理干净,冷水下锅,加入葱姜', '大火煮开后转小火浸煮20分钟', '关火焖10分钟后捞出过冰水', '鸡肉切块摆盘,调制生抽香油料汁', '淋上料汁,撒上香菜即可食用'],
        cookTime: 40,
        difficulty: '简单',
        image: '白切鸡'
      },
      {
        id: 4,
        name: '剁椒鱼头',
        category: '湘菜',
        ingredients: ['胖头鱼鱼头1个', '剁椒2勺', '生姜大蒜适量', '葱花少许', '蒸鱼豉油', '食用油'],
        steps: ['鱼头处理干净,对半切开摆盘', '铺上切好的姜片蒜末,铺满剁椒', '水开后上锅大火蒸15分钟', '倒掉盘中多余汁水,淋上蒸鱼豉油', '撒上葱花,浇上热油激发出香味即可'],
        cookTime: 50,
        difficulty: '中等',
        image: '剁椒鱼头'
      }
    ]
    return list.find(item => item.id === id)
  }
  // 页面加载接收路由参数
  aboutToAppear() {
    const params = router.getParams() as Record<string, Object>;
    if (params) {
      this.recipeId = params.recipeId as number;
      this.recipeName = params.recipeName as string;
      this.recipeDetail = this.getRecipeDetailById(this.recipeId) || null;
    }
  }

  // 返回上一页
  private backPrevPage() {
    router.back();
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text('←')
          .fontSize(22)
          .onClick(() => this.backPrevPage())

        Text(this.recipeName)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .margin({ left: 12 })
      }
      .width('100%')
      .padding({ left: 16, top: 10, bottom: 10 })
      .alignItems(VerticalAlign.Center)

      // 详情主体内容
      if (this.recipeDetail) {
        Scroll() {
          Column() {
            // 菜谱封面
            Column() {
              Text(this.recipeDetail.image)
                .fontSize(32)
                .fontColor('#fff')
            }
            .width('92%')
            .height(180)
            .borderRadius(12)
            .backgroundColor('#f97316')
            .justifyContent(FlexAlign.Center)

            // 基础信息
            Column() {
              Row() {
                Text(`分类:${this.recipeDetail.category}`)
                  .fontSize(14)
                  .fontColor('#666')
                Text(`难度:${this.recipeDetail.difficulty}`)
                  .fontSize(14)
                  .fontColor('#666')
                  .margin({ left: 20 })
              }

              Text(`制作时长:${this.recipeDetail.cookTime}分钟`)
                .fontSize(14)
                .fontColor('#666')
                .width('100%')
                .margin({ top: 8 })
            }
            .width('92%')
            .margin({ top: 16 })

            // 食材清单
            Column() {
              Text('🥬 食材清单')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .width('100%')

              ForEach(this.recipeDetail.ingredients, (ing: string) => {
                Text(`${ing}`)
                  .fontSize(14)
                  .width('100%')
                  .margin({ top: 6 })
                  .fontColor('#333')
              })
            }
            .width('92%')
            .margin({ top: 20 })

            // 烹饪步骤
            Column() {
              Text('🍳 烹饪步骤')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .width('100%')

              ForEach(this.recipeDetail.steps, (step: string, index: number) => {
                Column() {
                  Text(`步骤 ${index + 1}`)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#2563eb')
                    .width('100%')

                  Text(step)
                    .fontSize(14)
                    .fontColor('#444')
                    .width('100%')
                    .margin({ top: 4 })
                }
                .width('100%')
                .margin({ top: 12 })
              })
            }
            .width('92%')
            .margin({ top: 20, bottom: 30 })

          }
          .width('100%')
        }
        .layoutWeight(1)
      } else {
        Text('数据加载中...')
          .fontSize(16)
          .fontColor('#999')
          .layoutWeight(1)
      }

    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f8f9fa')
  }
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

五、常见问题排查与解决方案

5.1 搜索无匹配结果

问题原因:大小写敏感、仅匹配单一字段、空格干扰

解决方案:统一转换小写、去除首尾空格、同时匹配菜名和食材字段,代码中已内置该兼容逻辑。

5.2 详情页参数获取失败

问题原因:传递非基础类型参数、未做空值判断

解决方案:仅传递数字、字符串基础类型,接收参数时增加空值校验,避免报错。

5.3 分类切换列表不刷新

问题原因:列表绑定原始数据源,未绑定过滤后的数据

解决方案:List组件遍历过滤后的新数组,而非原始静态数组,实时响应筛选变化。

六、项目扩展优化方向

  • 功能扩展:新增菜谱收藏、一键分享、购物清单生成、烹饪计时器、视频教程嵌入

  • 体验优化:新增搜索历史记录、热门搜索、输入防抖、空状态优化

  • 性能优化:将ForEach替换为LazyForEach实现懒加载,适配大数据量菜谱列表

  • UI优化:添加网络图片加载、骨架屏、卡片点击动画效果

七、项目总结

本教程从零实现了一款完整的鸿蒙菜谱应用,覆盖了ArkUI开发中页面路由传参、实时搜索筛选、多条件数据过滤、卡片式布局、滚动容器、条件渲染等高频核心知识点。项目代码规范、逻辑清晰、无冗余,适配HarmonyOS NEXT最新API,所有代码可直接编译运行。

本项目的搜索、筛选、列表渲染逻辑可通用适配商城、资讯、工具类等绝大多数鸿蒙列表型应用,是鸿蒙入门进阶的绝佳实战案例。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐