HarmonyOS 实战中式美食食材大全页分类联动、网格稳定高度与食材检索入口设计食材页在菜谱应用里很容易被写成“很多按钮的集合”。但用户打开食材大全时真正想做的是快速确认某类食材、浏览可用材料并进一步联想到能做哪些菜。这个页面的工程重点不是堆食材而是让分类切换稳定、网格布局稳定、滚动体验稳定。这篇复盘基于已上架 HarmonyOS 应用 **中式美食**对应工程环境为 HarmonyOS 6.1.0(23)、ArkTS、ArkUI V2、Stage 模型。本文拆解 IngredientPage.ets 如何用左侧分类栏与右侧三列网格组织食材数据并通过 Computed activeItems、固定网格高度和视觉首图让食材浏览页既轻量又可维护。上图对应的是食材大全页的核心体验左侧保持分类导航右侧按当前分类展示食材网格。用户不需要从一长串文本里找材料而是按“肉类、水产、蔬菜、菌菇、蛋奶、豆制品、五谷、果干坚果”逐步缩小范围。本章导读| 章节位置 | 关键文件 | 解决的问题 | 验收入口 | | --- | --- | --- | --- | | 页面入口 | view/pages/IngredientPage.ets | 食材大全页面结构 | 首页“食材大全”入口 | | 数据模型 | IngredCat | 分类 key、展示名、食材数组的最小结构 | cats 常量 | | 状态切换 | Local active | 当前分类选择 | 左侧分类点击 | | 数据派生 | Computed activeItems | 根据分类派生右侧网格数据 | 右侧内容区 | | 布局稳定 | Grid 高度计算 | 三列网格不塌陷、不抖动 | 切换分类和滚动 |食材大全页为什么要做左右结构食材数据天然有层级。把所有食材平铺在一个页面里用户会快速迷失把每一类做成独立页面又会让导航成本变高。中式美食采用左右结构| 区域 | 作用 | 用户感知 | | --- | --- | --- | | 左侧分类栏 | 展示 8 个大类 | 我现在在哪一类 | | 右侧网格 | 展示当前类食材 | 我能快速扫到具体食材 | | 顶部 Hero | 给页面主题和视觉入口 | 这是一个完整模块不是临时列表 |这种结构适合移动端因为左侧分类宽度固定右侧内容可以滚动用户切换分类时不需要返回上一页也不会丢失上下文。数据模型保持小而直观页面内部定义了一个轻量接口typescriptinterface IngredCat {key: string;label: string;items: string[];}这个结构没有提前做复杂实体化因为当前食材页的目标是“浏览和分类联动”不是食材营养数据库。它只保留三个字段| 字段 | 用途 | | --- | --- | | key | 分类状态和 ForEach key | | label | 左侧分类显示 | | items | 右侧网格显示的食材名 |这种模型的好处是维护成本低。后续如果要接入食材详情、营养、忌口、搜索跳转可以再把 items: string[] 升级成对象数组而不是一开始就设计过重。分类数据放在页面内是否合理当前 IngredientPage 把 cats 作为页面私有数据typescriptprivate cats: IngredCat[] [{ key: meat, label: 肉类, items: [猪肉, 牛肉, 羊肉] },{ key: fish, label: 水产, items: [草鱼, 鲫鱼, 大虾] },{ key: veg, label: 蔬菜, items: [白菜, 青菜, 土豆] }];对当前规模来说这是合理的。原因是| 判断项 | 当前情况 | 结论 | | --- | --- | --- | | 数据是否会频繁远程更新 | 不会 | 暂不需要 Repository | | 是否被多个页面复用 | 暂时只在食材大全页使用 | 放在页面内可接受 | | 是否需要持久化 | 不需要 | 不进入 AppStorage | | 是否影响核心业务查询 | 暂不影响菜谱搜索 | 先保持轻量 |如果后续要做“点食材看菜谱”这份数据就应该下沉到 IngredientRepository 或与 DishRepository.search() 形成联动。但在当前版本页面私有数据更直接。active 是页面唯一交互状态食材页的交互状态只有一个typescriptLocal active: string meat;点击左侧分类时更新它typescript.onClick(() {this.active c.key;});这个状态设计很干净。页面不维护 activeIndex、activeLabel、activeItems 多份状态而是只保存当前 key其它都从 key 推导。| 状态设计 | 风险 | | --- | --- | | 同时保存 key、label、items | 三者可能不同步 | | 只保存 active key | 其它信息可计算状态源唯一 |移动端 UI 的很多错乱都是因为多个状态互相拷贝。这里让 active 成为唯一源头后续维护会轻松很多。activeItems 用 Computed 派生右侧网格数据通过 Computed 得到typescriptComputed get activeItems(): string[] {const c this.cats.find((x: IngredCat) x.key this.active);return c undefined ? [] : c.items;}这个写法的价值是分类切换时active 改变activeItems 自动重新计算右侧网格重绘。页面不需要在点击事件里手动 this.items c.items。| 场景 | Computed activeItems 的结果 | | --- | --- | | 默认进入 | 返回肉类食材 | | 点击水产 | 返回水产食材 | | key 不存在 | 返回空数组避免崩溃 |c undefined ? [] : c.items 是一个小兜底但很必要。即使以后分类 key 被改错页面也应该进入空状态而不是直接抛异常。左侧分类栏要给用户明确位置感左侧 SideBar() 里当前分类用品牌色和竖条标记typescriptif (this.active c.key) {Column().width(3).height(20).backgroundColor(AppColors.brandPrimary);} else {Column().width(3).height(20).backgroundColor(Color.Transparent);}Text(c.label).fontColor(this.active c.key ? AppColors.brandPrimary : AppColors.textSub).fontWeight(this.active c.key ? FontWeight.Bold : FontWeight.Normal)这个设计解决两个问题| 问题 | 处理方式 | | --- | --- | | 用户不知道当前分类 | 左侧竖条 品牌色文字 | | 切换时视觉跳动 | 竖条区域始终保留 3px 宽度 |注意这里没有在选中态时新增一个临时元素导致整体宽度变化而是未选中也保留透明竖条。这样点击分类时文字不会左右抖动。右侧网格要显式计算高度ArkUI 的 Grid 放在 Scroll 里时如果高度不稳定可能出现内容裁切或滚动范围不准确。当前实现显式计算高度typescriptGrid() {ForEach(this.activeItems, (name: string) {GridItem() {Column() {Text(name.charAt(0)).fontSize(AppFonts.xxl).fontColor(AppColors.textOnBrand).fontWeight(FontWeight.Bold);Text(name).fontSize(AppFonts.xs).fontColor(AppColors.textMain);}}}, (name: string, i: number) i : name);}.columnsTemplate(1fr 1fr 1fr).columnsGap(10).rowsGap(12).height(Math.ceil(this.activeItems.length / 3) * 130);这里最重要的是最后一行高度计算。三列网格下行数为 Math.ceil(activeItems.length / 3)每行按 130 预留空间。这样切换不同分类时滚动容器能拿到明确高度。食材卡片用首字占位避免资源依赖右侧食材项没有给每个食材配图而是用食材名首字做视觉占位typescriptText(name.charAt(0)).fontSize(AppFonts.xxl).fontColor(AppColors.textOnBrand).fontWeight(FontWeight.Bold);这是一种务实选择。食材页如果给每个食材配图会带来素材成本、风格一致性和包体体积问题。首字占位虽然简单但能保持整齐、可读、加载稳定。| 方案 | 优点 | 风险 | | --- | --- | --- | | 每个食材真实图片 | 信息更直观 | 素材成本高风格难统一 | | 首字占位卡 | 轻量、稳定、统一 | 视觉识别弱一些 | | 图标库映射 | 比首字更丰富 | 需要维护映射表 |当前版本选择首字占位是为了先把分类浏览体验做稳。顶部 Hero 让工具页有入口感食材页顶部使用 scene_ingredient 作为主题图并叠加横向渐变typescriptif (getImage(scene_ingredient) ! undefined) {Image(getImage(scene_ingredient)).width(100%).height(132).objectFit(ImageFit.Cover);}Column().width(100%).height(100%).linearGradient({angle: 90,colors: [[AppColors.brandPrimaryDark, 0], [#CC6B3C1F, 0.35], [#00000000, 0.7]]});工具页也需要入口感。没有 Hero 的食材页会像一个设置页有了主题图和短文案用户更容易理解这是“找食材、识食材”的模块。工程验收记录| 检查项 | 操作方式 | 通过标准 | | --- | --- | --- | | 默认状态 | 打开食材大全页 | 默认选中肉类 | | 分类切换 | 点击左侧 8 个分类 | 右侧食材同步变化 | | 选中态稳定 | 连续点击分类 | 文字不左右抖动 | | 网格高度 | 切换不同食材数量分类 | 滚动范围正确不裁切 | | 空 key 容错 | 模拟不存在的 active | 右侧为空不崩溃 | | 视觉首图 | 打开页面 | Hero 图片和文字可读 | | 深色模式 | 系统切换深色 | 页面背景、文字和卡片保持可读 |常见问题复盘| 问题 | 原因 | 处理方式 | | --- | --- | --- | | 右侧网格显示不全 | Grid 在 Scroll 中没有稳定高度 | 用行数计算 .height() | | 分类切换时文字抖动 | 选中态新增竖条占宽 | 未选中也保留透明竖条 | | 状态不同步 | 同时保存 key 和 items | 只保存 active用 Computed 派生 | | 食材数据过重 | 一开始就设计完整食材实体 | 当前版本先用 IngredCat 轻模型 | | 页面像临时列表 | 没有视觉入口 | 加入 Hero 和主题文案 |本章小结- 中式美食食材大全页的核心不是“食材多”而是分类、状态和网格布局足够稳定。- active 作为唯一状态源Computed activeItems 负责派生右侧数据能避免多个状态不同步。- 右侧三列网格显式计算高度是 ArkUI 滚动容器里保证内容不塌陷、不裁切的关键。