HarmonyOS 实战|中式美食食材大全页:分类联动、网格稳定高度与食材检索入口设计
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 | 给页面主题和视觉入口 | 这是一个完整模块,不是临时列表 |
这种结构适合移动端,因为左侧分类宽度固定,右侧内容可以滚动;用户切换分类时,不需要返回上一页,也不会丢失上下文。
数据模型保持小而直观
页面内部定义了一个轻量接口:
```typescript
interface IngredCat {
key: string;
label: string;
items: string[];
}
```
这个结构没有提前做复杂实体化,因为当前食材页的目标是“浏览和分类联动”,不是食材营养数据库。它只保留三个字段:
| 字段 | 用途 | | --- | --- | | `key` | 分类状态和 ForEach key | | `label` | 左侧分类显示 | | `items` | 右侧网格显示的食材名 |
这种模型的好处是维护成本低。后续如果要接入食材详情、营养、忌口、搜索跳转,可以再把 `items: string[]` 升级成对象数组,而不是一开始就设计过重。
分类数据放在页面内是否合理
当前 `IngredientPage` 把 `cats` 作为页面私有数据:
```typescript
private cats: IngredCat[] = [
{ key: 'meat', label: '肉类', items: ['猪肉', '牛肉', '羊肉'] },
{ key: 'fish', label: '水产', items: ['草鱼', '鲫鱼', '大虾'] },
{ key: 'veg', label: '蔬菜', items: ['白菜', '青菜', '土豆'] }
];
```
对当前规模来说,这是合理的。原因是:
| 判断项 | 当前情况 | 结论 | | --- | --- | --- | | 数据是否会频繁远程更新 | 不会 | 暂不需要 Repository | | 是否被多个页面复用 | 暂时只在食材大全页使用 | 放在页面内可接受 | | 是否需要持久化 | 不需要 | 不进入 AppStorage | | 是否影响核心业务查询 | 暂不影响菜谱搜索 | 先保持轻量 |
如果后续要做“点食材看菜谱”,这份数据就应该下沉到 `IngredientRepository` 或与 `DishRepository.search()` 形成联动。但在当前版本,页面私有数据更直接。
active 是页面唯一交互状态
食材页的交互状态只有一个:
```typescript
@Local active: string = 'meat';
```
点击左侧分类时更新它:
```typescript
.onClick(() => {
this.active = c.key;
});
```
这个状态设计很干净。页面不维护 `activeIndex`、`activeLabel`、`activeItems` 多份状态,而是只保存当前 key,其它都从 key 推导。
| 状态设计 | 风险 | | --- | --- | | 同时保存 key、label、items | 三者可能不同步 | | 只保存 active key | 其它信息可计算,状态源唯一 |
移动端 UI 的很多错乱,都是因为多个状态互相拷贝。这里让 `active` 成为唯一源头,后续维护会轻松很多。
activeItems 用 @Computed 派生
右侧网格数据通过 `@Computed` 得到:
```typescript
@Computed 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()` 里,当前分类用品牌色和竖条标记:
```typescript
if (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` 里时,如果高度不稳定,可能出现内容裁切或滚动范围不准确。当前实现显式计算高度:
```typescript
Grid() {
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 预留空间。这样切换不同分类时,滚动容器能拿到明确高度。
食材卡片用首字占位,避免资源依赖
右侧食材项没有给每个食材配图,而是用食材名首字做视觉占位:
```typescript
Text(name.charAt(0))
.fontSize(AppFonts.xxl)
.fontColor(AppColors.textOnBrand)
.fontWeight(FontWeight.Bold);
```
这是一种务实选择。食材页如果给每个食材配图,会带来素材成本、风格一致性和包体体积问题。首字占位虽然简单,但能保持整齐、可读、加载稳定。
| 方案 | 优点 | 风险 | | --- | --- | --- | | 每个食材真实图片 | 信息更直观 | 素材成本高,风格难统一 | | 首字占位卡 | 轻量、稳定、统一 | 视觉识别弱一些 | | 图标库映射 | 比首字更丰富 | 需要维护映射表 |
当前版本选择首字占位,是为了先把分类浏览体验做稳。
顶部 Hero 让工具页有入口感
食材页顶部使用 `scene_ingredient` 作为主题图,并叠加横向渐变:
```typescript
if (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 滚动容器里保证内容不塌陷、不裁切的关键。
