HarmonyOS菜谱——五个页面的布局模式对比分析
适用读者: 已经读完菜谱系列其他文章、想从"布局复用"角度理解项目结构的开发者。本文对比五个页面的布局结构,提炼可复用的模式,搞清楚"哪些布局可以直接套用、哪些需要根据场景调整"。
完整效果

一、五个页面的骨架结构
五个页面的顶层布局可以归纳为两种骨架:
骨架一:Column + Scroll(四个页面)
Column 页面容器
├→ Row 导航栏
└→ Scroll 可滚动内容
└→ Column 垂直排列各区块
├→ 区块 1
├→ 区块 2
└→ Blank().height(20) 底部留白
使用页面: CategoryPage、FavoritesPage、ShoppingListPage、RecipeDetail
导航栏固定在顶部不参与滚动,Scroll 包裹所有内容区块。这是"顶部导航 + 内容滚动"的标准骨架——大多数移动端页面都用这种结构。
为什么导航栏要放在 Scroll 外面? 如果放在 Scroll 里面,用户往下滚动时导航栏会跟着滚走——返回按钮消失,用户无法快速返回上一页。放在外面保证导航栏始终可见。
骨架二:Stack + Scroll + Row(Index)
Stack({ alignContent: Alignment.Bottom }) 层叠容器
├→ Scroll 可滚动内容
│ └→ Column 垂直排列
│ ├→ Header
│ ├→ Search
│ ├→ Categories
│ ├→ Recipes
│ └→ Blank().height(90) 底部导航栏预留
└→ Row 底部导航栏(固定)
└→ NavBtn × 3
使用页面: Index
首页需要用 Stack 把底部导航栏固定在底部——Scroll 内容可以滚动,导航栏始终可见。Blank().height(90) 预留了导航栏的高度(60px + 上方留白),防止最后一个菜谱卡片被导航栏遮挡。
alignContent: Alignment.Bottom 的关键作用。 Stack 默认是 Alignment.Center——两层都居中叠放。改成 Bottom 后,导航栏(Row)贴底,Scroll 内容在上方。如果忘了这个属性,导航栏会跑到屏幕中间。
两种骨架的选择标准
| 场景 | 骨架 | 原因 |
|---|---|---|
| 有底部导航栏 | Stack + Scroll + Row | 导航栏需要固定在底部 |
| 无底部导航栏 | Column + Scroll | 导航栏在顶部,不需要层叠 |
| 内容不需要滚动 | Column | 一屏能放下所有内容 |
菜谱 App 只有首页有底部导航栏,所以只有首页用骨架二。其他四个页面都是骨架一。
二、导航栏的统一结构与四个变体
四个非首页页面的导航栏基础结构完全一致:
Row() {
// 左侧返回按钮
Row() { SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1]) }
.width(34).height(34).borderRadius(17).backgroundColor('rgba(0,0,0,0.03)')
.justifyContent(FlexAlign.Center).onClick(() => { router.back() })
// 中间标题
Text(title).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1)
.margin({ left: 10 }).layoutWeight(1)
// 右侧内容(各页面不同)
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })

返回按钮的嵌套结构拆解
外层 Row 的作用——扩大点击区域。 SymbolGlyph 本身只有图标那么大(约 16×16),用户很难精确点击。外层 Row 设定 34×34 的点击区域,让触摸体验更好。
justifyContent(FlexAlign.Center) 让图标在 34×34 区域内居中。 没有这行,SymbolGlyph 会靠左对齐,视觉上不在按钮中心。
backgroundColor('rgba(0,0,0,0.03)') 给按钮一个极浅的底色。 3% 黑色透明度几乎看不出颜色,但给了用户一个"这是可点击区域"的视觉暗示。如果完全透明,按钮会和背景融为一体。
layoutWeight(1) 让标题占据所有剩余空间。 无论右侧有没有内容、内容多宽,标题都不会被挤压。这和 CSS 的 flex: 1 效果一样。
四个变体的右侧内容
| 页面 | 标题 | 右侧内容 | 实现方式 |
|---|---|---|---|
| CategoryPage | 动态分类名(this.cat) |
菜谱数量 | Text(this.recipes.length + '道') |
| FavoritesPage | “我的收藏” | 菜谱数量 | Text(this.recipes.length + '道') |
| ShoppingListPage | “采购清单” | "清空"按钮 | Text('清空').onClick(...) |
| RecipeDetail | 动态菜名(this.recipe.name) |
收藏按钮 | Text(this.fav ? '❤️' : '🤍').onClick(...) |
踩坑记录: 最初 RecipeDetail 的导航栏标题写了固定字符串"菜谱详情",用户反馈应该显示具体菜名。改成 this.recipe ? this.recipe.name : '' 后,导航栏标题随菜谱动态变化。这个细节能让用户随时知道自己在看哪道菜。
ShoppingListPage 的"清空"按钮放在导航栏而不是列表底部。 原因是:清空是全局操作,放在导航栏比放在列表里更显眼。用户不需要滚动到列表底部就能找到清空按钮。fontColor(A) 用红色文字暗示"这是危险操作"。
三、菜谱卡片的两种实现
菜谱卡片在三个页面重复出现,但有细微差异:
首页卡片
Row() {
Text(r.icon).fontSize(36).width(64).height(64).borderRadius(16)
.backgroundColor('#F5F5FA').textAlign(TextAlign.Center)
Column() {
Text(r.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)
Text(r.desc).fontSize(11).fontColor(T3).maxLines(1).margin({ top: 2 })
Row() {
Text('⏱ ' + r.time).fontSize(10).fontColor(T2)
Text(' · ' + r.difficulty).fontSize(10).fontColor(T2)
Text(' · ' + r.servings).fontSize(10).fontColor(T2)
}.margin({ top: 4 })
}.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}
.width('100%').padding(14).backgroundColor(CW).borderRadius(16)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.03)', offsetY: 1 })

分类页/收藏页卡片
Row() {
Text(r.icon).fontSize(36).width(64).height(64).borderRadius(16)
.backgroundColor('#F5F5FA').textAlign(TextAlign.Center)
Column() {
Text(r.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)
Text(r.desc).fontSize(11).fontColor(T3).maxLines(1).margin({ top: 2 })
Text('⏱ ' + r.time + ' · ' + r.difficulty).fontSize(10).fontColor(T2)
.margin({ top: 4 })
}.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)

两种实现的差异
| 部分 | 首页卡片 | 分类页/收藏页卡片 |
|---|---|---|
| 第三行信息 | 三个独立 Text + Text(' · ') 分隔 |
一个 Text 拼接字符串 |
| 显示内容 | 时间+难度+人数 | 时间+难度 |
| 卡片阴影 | 有(shadow radius:4) |
无 |
第三行的两种写法对比: 首页用三个独立 Text,每个 Text 一个属性,中间用 Text(' · ') 分隔。分类页/收藏页用一个 Text 拼接所有属性。最终视觉效果一样,但代码结构不同。三种写法各有优劣——三个独立 Text 更灵活(可以单独控制每个属性的颜色),拼接字符串更简洁(一行搞定)。
首页卡片有阴影,其他页面没有。 首页是入口页面,卡片阴影增加了层次感,让内容和背景有分离。分类页和收藏页是列表页面,卡片密集排列,加阴影会让页面看起来太"厚重"。
卡片的通用模板
所有菜谱卡片都遵循同一个结构:
Row 卡片容器(白色背景 + 圆角 + padding 14)
├→ Text(icon) 左侧 emoji 图标(64×64 固定区域 + #F5F5FA 底色)
├→ Column + layoutWeight(1) 中间信息区(弹性宽度)
│ ├→ Text(name) 菜名(15px 粗体深色)
│ ├→ Text(desc) 描述(11px 浅灰 T3,maxLines 1)
│ └→ Text/Row(属性) 属性行(10px 灰色 T2)
└→ SymbolGlyph(chevron_right) 右侧箭头(14px 浅灰 #DDD)
每个部分的设计理由:
- 左侧 64×64 emoji 底座: 固定尺寸保证所有卡片对齐,无论 emoji 内容多大。
#F5F5FA极浅灰背景让 emoji 有一个"底座",不会和白色卡片融为一体。 - 中间
layoutWeight(1): 弹性宽度——无论菜名多长,右侧箭头始终固定在最右。 maxLines(1): 菜谱描述可能很长,限制一行保证所有卡片等高。- 右侧箭头: 告诉用户"可点击查看详情"。
#DDD浅灰色不抢焦点,只在用户注意时才看到。
四、空状态的统一模式
FavoritesPage 和 ShoppingListPage 都有空状态处理:
if (this.recipes.length === 0) {
Column() {
Text('❤️').fontSize(64).opacity(0.2).margin({ bottom: 16 })
Text('还没有收藏').fontSize(16).fontColor(T2)
Text('浏览菜谱时点击 ❤️ 即可收藏').fontSize(13).fontColor(T3).margin({ top: 4 })
}.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}
空状态的三个要素
| 要素 | 作用 | 样式 | 设计理由 |
|---|---|---|---|
| 大 emoji | 视觉锚点 | 64px + opacity(0.2) | 吸引注意力但淡化,不抢文字焦点 |
| 主提示 | 告诉用户当前状态 | 16px 灰色 T2 | 直接说明"没有数据" |
| 副提示 | 告诉用户怎么操作 | 13px 浅灰 T3 | 提供解决方案 |
opacity(0.2) 的设计意图。 大 emoji 用 64px 是为了吸引注意力,但 opacity(0.2) 让它变淡——用户看到淡化的 emoji 后,自然会往下看文字。如果 emoji 不淡化,用户的注意力会被 emoji 吸走,忽略下方的操作提示。
layoutWeight(1) + justifyContent(FlexAlign.Center) 的组合。 layoutWeight(1) 让空状态 Column 占据所有剩余空间,justifyContent(FlexAlign.Center) 让内容垂直居中。没有 layoutWeight(1),Column 只会占内容高度,居中效果不明显——内容会贴在导航栏下方。
两个页面的空状态文案对比
| 页面 | 主提示 | 副提示 | 指向 |
|---|---|---|---|
| FavoritesPage | 还没有收藏 | 浏览菜谱时点击 ❤️ 即可收藏 | 指向"菜谱详情页的收藏按钮" |
| ShoppingListPage | 清单是空的 | 在菜谱详情页点击"加入采购清单" | 指向"菜谱详情页的加入清单按钮" |
两个副提示都指向了具体的操作入口——不是笼统地说"去浏览菜谱",而是告诉用户"在哪个页面、点击哪个按钮"。这种"问题 + 具体解决方案"的文案模式比"暂无数据"更有帮助。
踩坑记录: 最初空状态没加 layoutWeight(1),心形图标和文字贴在导航栏下方,视觉上很拥挤。加了 layoutWeight(1) 后 Column 占据所有剩余空间,配合居中实现垂直居中。
五、列表渲染的三种模式
模式一:固定列表(不需要滚动)
// ShoppingListPage 的采购清单
Column({ space: 4 }) {
ForEach(this.items, (item: string, idx: number) => {
Row() { /* 列表项 */ }
})
}
采购清单项数通常较少(5-10 个食材),不需要滚动。Column({ space: 4 }) 自动处理间距,代码最简洁。
模式二:可滚动列表
// CategoryPage / FavoritesPage
Scroll() {
Column({ space: 10 }) {
ForEach(this.recipes, (r: Recipe) => {
Row() { /* 菜谱卡片 */ }
})
Blank().height(20)
}.width('100%').padding({ left: 16, right: 16 })
}
菜谱列表可能超过一屏,需要 Scroll 包裹。Blank().height(20) 在列表底部留白,防止最后一个卡片被屏幕边缘截断。
Blank().height(20) 而不是 margin({ bottom: 20 }) 的原因。 Blank 在 Column 里会占据固定高度的空间,效果等同于底部留白。如果用 margin,需要加在 ForEach 的最后一项上——但 ForEach 的最后一项是动态的(收藏数量变化),无法提前知道要加在哪一项。Blank 更简单。
模式三:分组列表
// RecipeDetail 的六个区块
Column() {
Column() { /* Hero */ }.padding(24).backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 16, right: 16, bottom: 16 })
Column() { /* 食材 */ }.padding(18).backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 16, right: 16, bottom: 16 })
Column() { /* 步骤 */ }.padding(18).backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 16, right: 16, bottom: 16 })
// ...
}
详情页的六个区块是"分组列表"——每个区块是独立的白色容器,区块之间有间距。这不是 ForEach 渲染的列表,而是手动排列的区块。每个区块的样式独立调整(Hero padding 24,其他 padding 18),比 ForEach 更灵活。
三种模式的选择标准
| 场景 | 模式 | 原因 |
|---|---|---|
| 项数固定且少(< 10) | Column + ForEach | 不需要滚动,代码最简洁 |
| 项数不确定或多 | Scroll + Column + ForEach | 超出一屏时可滚动 |
| 区块结构各不相同 | 手动排列 Column | 每个区块样式不同,无法用 ForEach 统一 |
六、条件渲染的三种用法
用法一:if-else 切换不同 UI 结构
if (this.recipes.length === 0) {
// 空状态:大 emoji + 提示文字
} else {
// 列表:Scroll + ForEach
}
数组为空和不为空时,UI 结构完全不同——空状态是居中的 emoji + 文字,列表是可滚动的卡片。这种"两种完全不同的结构"只能用 if-else,不能用三元表达式(三元只能切换属性,不能切换结构)。
用法二:if 控制区块显隐
if (this.recipe) {
Scroll() { /* 所有内容 */ }
}
数据加载成功后才渲染内容。this.recipe 可能是 undefined(传入的 id 无效时),if (this.recipe) 保证只在数据存在时才访问 this.recipe.name 等属性。
为什么不用可选链? this.recipe?.name 虽然也能避免 undefined 错误,但会让整个 build 方法变成一长串可选链调用,可读性差。if (this.recipe) 把"数据是否加载成功"的判断集中在一个地方,代码更清晰。
用法三:三元表达式切换属性
// 收藏按钮
Text(this.fav ? '❤️' : '🤍').fontSize(20)
// 计时器按钮
Text(this.timerOn ? '⏸ 停止' : '▶ 开始')
.backgroundColor(this.timerOn ? '#FF6B6B' : A)
// 底部导航栏
Text(label === '菜谱' ? '📖' : (label === '收藏' ? '❤️' : '🛒'))
元素始终存在,只改变文字内容、颜色、背景色。三元表达式适合这种"同一个元素的两种状态"切换。
三种用法的选择标准
| 场景 | 用法 | 原因 |
|---|---|---|
| 两种完全不同的 UI 结构 | if-else | 空状态和列表结构不同 |
| 数据是否存在 | if | 数据不存在时不渲染,避免 undefined 报错 |
| 文字/颜色/图标切换 | 三元 | 元素始终存在,只改属性 |
七、间距系统的完整规则
页面级间距
| 位置 | 值 | 出现次数 | 规律 |
|---|---|---|---|
| 页面左右 padding | 16px | 所有页面 | 统一边距,切换页面时宽度感一致 |
| 区块之间 margin bottom | 16px | 详情页 5 处 | 大区块之间用统一间距 |
| 列表项之间 space | 10px | 3 个页面 | 卡片之间用统一间距 |
| 底部留白 | 20px | 3 个页面 | 防止最后一个元素被截断 |
区块级间距
| 位置 | 值 | 设计理由 |
|---|---|---|
| Hero 区块 padding | 24px | 视觉重心,留白多一点 |
| 其他区块 padding | 16-18px | 内容区标准留白 |
| 标题行 margin bottom | 8-10px | 标题和内容之间的呼吸感 |
| 列表项 padding | 12-14px | 卡片内边距 |
| 同区块小元素间距 | 2-4px | 紧密关联的元素用小间距 |
间距选择的核心规律
外大内小: 区块之间用大间距(16px),区块内元素之间用小间距(2-10px)。用户通过间距大小区分"这是不同功能区"和"这是同一功能区内的元素"。
统一边距: 所有页面的左右 padding 统一为 16px。用户在切换页面时不会感到"页面宽度变了"。
功能相关紧贴: 详情页的"份量调整"和"计时器"之间没有额外 margin——因为两个功能紧密关联(都是烹饪过程中的辅助工具)。功能独立的区块之间用 16px 间距拉开。
八、底部导航栏的完整实现
@Builder NavBtn(label: string, active: boolean) {
Column({ space: 2 }) {
Text(label === '菜谱' ? '📖' : (label === '收藏' ? '❤️' : '🛒')).fontSize(18)
Text(label).fontSize(9).fontColor(active ? '#FF6B6B' : '#C0BFC6')
}.onClick(() => {
if (label === '收藏') router.pushUrl({ url: 'pages/FavoritesPage' })
else if (label === '清单') router.pushUrl({ url: 'pages/ShoppingListPage' })
})
}
三元表达式嵌套选择 emoji
label === '菜谱' ? '📖' : (label === '收藏' ? '❤️' : '🛒') 根据标签名选择对应图标。这种写法比 if-else 链更紧凑,但超过三个分支时可读性会下降。当前正好三个 tab,三元嵌套刚好够用。
“菜谱” tab 不跳转的设计
onClick 里只处理了"收藏"和"清单"的跳转——“菜谱” tab 没有跳转逻辑。因为 Index 本身就是菜谱页面,重新 push 会导致页面重建,搜索框内容丢失。
踩坑记录: 最初"菜谱" tab 也写了 router.pushUrl({ url: 'pages/Index' }),点击后页面闪烁,搜索框内容丢失。去掉跳转后问题解决。这是 @Entry 页面的特性——重复 push 同一个 @Entry 页面会重建实例。
选中状态的视觉区分
| 属性 | 选中(active=true) | 未选中(active=false) |
|---|---|---|
| 文字颜色 | #FF6B6B(主色红) | #C0BFC6(浅灰) |
| emoji | 正常显示 | 正常显示 |
选中和未选中只通过文字颜色区分——emoji 不变。这种"颜色区分"比"图标切换"更轻量,不需要准备两套图标资源。
九、整体布局的嵌套层次
Index(骨架二)
└→ Stack(Alignment.Bottom)
├→ Scroll
│ └→ Column
│ ├→ Row (Header) ← Stack + 渐变圆
│ ├→ Row (Search) ← 圆角输入框
│ ├→ Row({ space: 8 }) + ForEach ← 分类横向排列
│ ├→ Column({ space: 10 }) + ForEach ← 菜谱卡片列表
│ └→ Blank(90) ← 导航栏预留
└→ Row + @Builder NavBtn × 3 ← 底部导航
CategoryPage / FavoritesPage(骨架一)
└→ Column
├→ Row (导航栏)
└→ Scroll
└→ Column
├→ ForEach (列表项)
└→ Blank(20)
ShoppingListPage(骨架一,无 Scroll)
└→ Column
├→ Row (导航栏)
├→ Row (输入框 + 添加按钮)
└→ if (空状态) / Column + ForElse (列表)
RecipeDetail(骨架一)
└→ Column
├→ Row (导航栏)
└→ Scroll
└→ Column
├→ Column (Hero) ← padding 24
├→ Column (食材) ← padding 18 + 加入清单按钮
├→ Column (步骤) ← padding 18 + 编号时间线
├→ Column (小贴士) ← padding 18 + 暖色背景
├→ Column (份量调整) ← padding 16
├→ Column (计时器) ← padding 16
└→ Blank(20)
嵌套层次的规律: 最深 4 层(Stack → Scroll → Column → Row),最浅 2 层(Column → Row)。嵌套越深的页面结构越复杂(Index 有 4 层),嵌套越浅的页面越简单(ShoppingListPage 有 2 层)。
每个 Column 容器就是一个功能区块。 看到一个新的 Column 就知道是一个独立的功能区——可以独立调整 padding、背景色、圆角,不影响其他区块。这种"区块 = Column"的模式让代码结构清晰,维护时能快速定位到目标区块。
更多推荐


所有评论(0)