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

完整效果
在这里插入图片描述
在这里插入图片描述

一、五个页面的骨架结构

五个页面的顶层布局可以归纳为两种骨架:

骨架一: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"的模式让代码结构清晰,维护时能快速定位到目标区块。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐