HarmonyOS智能家居——UI布局模式的深度拆解
写完五个页面后发现,所有 UI 都是由五种基础布局(Stack、Row、Column、Grid、Scroll)嵌套组合出来的。这篇不讲"怎么用布局API",而是拆解项目里每个页面的具体嵌套结构,搞清楚"这行代码为什么放在这里、去掉会怎样"。
完整效果

一、Stack:层叠布局的三种用法
Stack 在项目里出现了三种完全不同的用法,每种都是"底层放装饰、上层放内容"的模式。
用法一:背景 + 内容层叠
首页 Header:
Stack() {
// 底层:渐变背景圆
Column() {}.width(44).height(44).borderRadius(22)
.linearGradient({ angle: 135, colors: [[A, 0], ['#A78BFA', 1]] })
// 上层:emoji 图标
Text('🏠').fontSize(20)
}
Stack 的默认对齐是 Alignment.Center——两层都居中叠放。底层是一个 44×44 的渐变圆(Column 没有内容,纯色块),上层是 🏠 emoji。最终效果是一个渐变色圆形背景上叠了一个图标。
为什么用 Stack 而不是给 Text 直接加背景?因为 Text 的 backgroundColor 是矩形的——即使 Text 只有两个字符宽,背景也是整个矩形区域。Stack 让底层可以用 borderRadius(22) 做成圆形,上层 Text 不受底层形状影响。
用法二:状态卡片的渐变背景
首页 Status Card:
Stack({ alignContent: Alignment.Center }) {
// 底层:三色渐变背景
Column() {}.width('100%').height(110).borderRadius(20)
.linearGradient({ angle: 135, colors: [[A, 0], ['#7C6FF0', 0.5], ['#A78BFA', 1]] })
// 上层:设备状态文字 + emoji
Row() {
Column() {
Text('设备状态').fontSize(14).fontColor('rgba(255,255,255,0.7)')
Text(this.devCount + '/' + DEVICES.length + ' 在线')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White)
}.alignItems(HorizontalAlign.Start)
Text('🟢').fontSize(44)
}.width('100%').padding(20)
}

alignContent: Alignment.Center 让上下两层都居中。底层是三色渐变(紫 → 淡紫 → 浅紫),上层是白色文字和 emoji。rgba(255,255,255,0.7) 让"设备状态"标题半透明白,Color.White 让在线数全白——同色系但不同透明度,形成标题和内容的层次。
Stack 的外层容器 .width('100%').height(110).borderRadius(20) 设定了卡片的尺寸和圆角。这个尺寸同时约束了底层和上层——底层的渐变背景填满 110vp 高度,上层的文字在这个范围内排列。
用法三:导航栏返回按钮的点击区域扩展
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)
Text('房间详情').fontSize(20)...layoutWeight(1)
}
虽然这行没有显式用 Stack,但外层 Row 的作用和 Stack 类似——扩大了返回按钮的点击区域。SymbolGlyph 本身只有图标那么大(约 16×16),外层 Row 扩大到 34×34,让用户不需要精确点击图标中心。
如果外层不用 Row 而是直接给 SymbolGlyph 加 padding:
SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])
.padding(9) // 44 - 20 = 24, /2 = 12, 约 9vp
padding 虽然扩大了点击区域,但不会显示背景色。用 Row + backgroundColor 能同时扩大点击区域和显示一个浅色底色,视觉上更明确。
二、Row + Blank:标题行的固定模式
项目里所有"标题 + 右侧信息"的行都用同一个模式:
Row() {
Text('房间').fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1)
Blank()
Text(ROOMS.length + ' 个').fontSize(12).fontColor(T2)
}
.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 10 })
逐行分析:
Text('房间').fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1)——左侧标题,17px 粗体深色。
Blank()——弹性空白。这是 Row 里的关键组件——它占据所有剩余空间,把左右两侧的文字推到两端。没有 Blank,两个 Text 会紧挨在一起。
Text(ROOMS.length + ' 个').fontSize(12).fontColor(T2)——右侧信息,12px 灰色。字号比标题小(12 vs 17),颜色比标题淡(T2 vs T1),形成视觉上的主次关系。
.width('100%').padding({ left: 16, right: 16 })——行宽 100%,左右各留 16px 边距。这个边距值和页面内其他元素(卡片、列表)的左右边距一致,保证所有元素左对齐。
这个模式在项目里出现了 8 次:
| 页面 | 标题行内容 |
|---|---|
| Index | 房间 + 5 个 |
| Index | 今日能耗 + 2.8 kWh |
| Index | 智能场景 |
| RoomPage | 房间名 + 设备数 |
| EnergyPage | 今日能耗 + 2.8 kWh |
| EnergyPage | 24小时用电分布 |
| EnergyPage | 各房间能耗 |
| EnergyPage | 节能建议 |
每次都用 Row + Text + Blank + Text 的结构,只是标题和右侧内容不同。如果以后要统一修改标题行的样式(比如改字号或间距),需要改 8 个地方。抽成 Builder 可以解决:
@Builder SectionTitle(title: string, extra?: string) {
Row() {
Text(title).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1)
Blank()
if (extra !== undefined) {
Text(extra).fontSize(12).fontColor(T2)
}
}.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 10 })
}
但当前没有抽取——因为 8 次重复的维护成本在可接受范围内,且部分标题行有额外的 margin 或 padding 微调,统一抽取需要处理这些差异。
三、Column 的垂直间距控制
方式一:margin
Text('快捷控制').fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1)
.width('100%').padding({ left: 16 }).margin({ bottom: 10 })
margin 控制元素和相邻元素之间的距离。margin({ bottom: 10 }) 在标题下方留 10px 空间,让标题和下方的 Grid 之间有间距。
方式二:Column({ space: N })
Column({ space: 10 }) {
this.RoomEnergy('🛋️ 客厅', 1.2, 40)
this.RoomEnergy('🛏️ 卧室', 0.8, 28)
this.RoomEnergy('🍳 厨房', 0.5, 18)
this.RoomEnergy('书房', 0.2, 8)
this.RoomEnergy('🛁 浴室', 0.1, 6)
}
space: 10 让 Column 里的每个子元素之间自动加 10px 间距。比手动给每个子元素加 margin 更简洁——不需要在每个子元素上都写 margin({ top: 10 })。
方式三:Blank().height(N)
Column() {
// ... 所有内容
Blank().height(100)
}
空白填充。Blank().height(100) 在内容底部加 100vp 空白,保证最后一个卡片不会被底部导航栏遮挡。这个 100vp 约等于导航栏高度(64vp)+ 上方一些留白(约 36vp)。
三种方式的使用场景
| 方式 | 场景 | 优点 | 缺点 |
|---|---|---|---|
| margin | 元素间距不均匀 | 精确控制每个方向 | 代码啰嗦 |
| Column space | 子元素间距均匀 | 一行代码搞定 | 只能统一间距 |
| Blank.height | 内容底部留白 | 简单直观 | 只适合底部填充 |
首页的 Scroll Column 里混合使用了三种方式:标题行用 margin(每个标题的 margin 值可能不同),房间卡片用 Row space(间距统一 10),底部用 Blank(固定 100vp)。这种"混合使用"是实际开发中的常态——没有一种方式能覆盖所有场景。
四、横向 Scroll:内容溢出的处理
首页房间列表
Scroll() {
Row({ space: 10 }) {
ForEach(ROOMS, (room: Room) => {
Column() {
Text(room.icon).fontSize(30).margin({ bottom: 8 })
Text(room.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(T1)
Text(room.activeCount + '/' + room.deviceCount + ' 设备')
.fontSize(10).fontColor(T2)
}.width(100).padding(14).backgroundColor(CW).borderRadius(16)
.border({ width: 1.5, color: room.color + '30' })
.onClick(() => { router.pushUrl(...) })
})
}.width('100%').padding({ left: 16, right: 16 })
}
.width('100%').scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)

逐层拆解:
最外层 Scroll——设定滚动方向为水平(ScrollDirection.Horizontal),隐藏滚动条(BarState.Off)。Scroll 的宽度是 100%,但内容(Row)的宽度可能超过 100%——5 个房间卡片,每个 100vp + 10vp 间距 = 550vp,超过屏幕宽度。Scroll 让超出部分可以滑动查看。
中间层 Row({ space: 10 })——所有卡片横向排列,间距 10vp。Row 的宽度是 100%,但这不意味着它只占 100%——Row 的实际宽度由子元素决定,当子元素总宽度超过 Row 的 100% 设定时,Row 会超出 Scroll 的可见区域,触发滚动。
内层 Column——每个卡片是固定 100vp 宽的纵向布局。.width(100) 保证所有卡片等宽,不管内容(emoji、名称、设备数)长度如何。
padding({ left: 16, right: 16 })——Row 左右各留 16px 边距。这保证第一个卡片不会紧贴屏幕左边缘,最后一个卡片不会紧贴右边缘。但注意:当滚动到最后一个卡片时,右边距可能不够——这是横向 Scroll 的常见问题,可以通过给 Row 最后加一个空白占位解决。
首页场景列表
Scroll() {
Row({ space: 10 }) {
ForEach(this.scenes, (s: Scene, idx: number) => {
Column() {
Text(s.icon).fontSize(28).margin({ bottom: 6 })
Text(s.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(T1)
Text(s.active ? '运行中' : '点击启动').fontSize(9)
.fontColor(s.active ? s.color : T2).margin({ top: 2 })
}.width(90).padding(12).backgroundColor(s.active ? s.color + '15' : CW)
.borderRadius(14)
.border({ width: s.active ? 1.5 : 1, color: s.active ? s.color : '#F0EEF4' })
.onClick(() => { this.activateScene(idx) })
})
}.width('100%').padding({ left: 16, right: 16 })
}
.width('100%').scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
和房间列表的结构几乎一样——Scroll + Row + ForEach + Column。区别是卡片宽度(90 vs 100)和卡片内容(场景有状态文字,房间有设备数)。
两个横向 Scroll 的共同结构:
Scroll(水平滚动, 隐藏滚动条)
└→ Row(space: 10, 左右 padding: 16)
└→ ForEach(数据数组)
└→ Column(固定宽度, padding, 圆角, 点击事件)
这个结构可以作为"横向卡片列表"的标准模板,适用于任何需要横向滚动的场景。
五、Grid 的 2×2 布局
Grid() {
GridItem() {
Row() {
Text('💡').fontSize(22).margin({ right: 10 })
Text('客厅主灯').fontSize(12).fontColor(T1).layoutWeight(1)
Text(DEVICES[0].on ? '🟢' : '⚪').fontSize(16)
}.width('100%').padding(12).backgroundColor(CW).borderRadius(14)
.onClick(() => { toggleDevice(1); this.devCount = getActiveCount() })
}
GridItem() { /* 客厅空调 */ }
GridItem() { /* 电视 */ }
GridItem() { /* 床头灯 */ }
}
.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 20 })
.columnsTemplate('1fr 1fr')——定义两列等宽。1fr 是弹性单位,表示"剩余空间的一份"。两个 1fr 表示两列平分可用宽度。去掉 padding 后,每列宽度 = (屏幕宽度 - 32px 左右边距 - 10px 列间距) / 2。
.columnsGap(10).rowsGap(10)——列间距和行间距都是 10vp。如果只设 columnsGap 不设 rowsGap,行间距默认为 0,两行卡片会紧挨在一起。
每个 GridItem 内部是 Row——emoji + 名称 + 状态指示器横向排列。layoutWeight(1) 让名称占据所有剩余空间,状态指示器固定在右侧。.width('100%') 让 Row 填满 GridItem 的宽度。
为什么不直接用 Column + Row 组合?
// 也可以实现 2×2 布局
Row() {
Column() { /* 客厅主灯 */ }.layoutWeight(1)
Column() { /* 客厅空调 */ }.layoutWeight(1)
}.margin({ bottom: 10 })
Row() {
Column() { /* 电视 */ }.layoutWeight(1)
Column() { /* 床头灯 */ }.layoutWeight(1)
}
手动嵌套 Row + Column 也能做 2×2,但代码更啰嗦——需要自己管理每行的间距、每列的宽度。Grid 通过 columnsTemplate 自动处理这些,代码更简洁。当列数或行数变化时(比如改成 3 列),Grid 只需要改 columnsTemplate('1fr 1fr 1fr'),手动嵌套需要重构整个布局。
六、Stack(alignContent) 的对齐控制
项目里 Stack 出现了两种对齐方式:
默认对齐(Alignment.Center)
Stack() {
Column() {}.width(44).height(44).borderRadius(22)
.linearGradient(...)
Text('🏠').fontSize(20)
}
没有指定 alignContent,默认是 Alignment.Center——所有子元素居中叠放。适合"背景 + 图标"这种简单层叠。
显式对齐(Alignment.Bottom)
Stack({ alignContent: Alignment.Bottom }) {
Scroll() { Column() { /* 所有内容 */ } }
Row() { /* 底部导航栏 */ }
}
首页用 Alignment.Bottom 让导航栏固定在底部,Scroll 内容在上方滚动。Stack 的高度是 100%,Scroll 和 Row 都在同一层叠放,Bottom 对齐让 Row 贴底。
如果改成默认的 Center 对齐,导航栏会跑到屏幕中间——因为 Row 的高度只有 64vp,居中后会在屏幕中间位置,上下各有大量空白。
对齐方式的选择
| 场景 | 对齐方式 | 原因 |
|---|---|---|
| 背景 + 前景 | Center(默认) | 两层都居中,不需要额外配置 |
| 固定头部/底部 | Bottom 或 Top | 内容层滚动,固定层贴边 |
| 全屏覆盖 | Center 或 Stretch | 弹窗/蒙层需要覆盖整个屏幕 |
七、整体页面的嵌套结构模板
五个页面的嵌套结构可以归纳为一个通用模板:
Stack({ alignContent: Alignment.Bottom }) // 层叠容器
├→ Scroll() // 可滚动内容
│ └→ Column() // 垂直排列
│ ├→ 导航栏 Row // 固定顶部
│ ├→ 卡片/列表区域 // 核心内容
│ │ ├→ 标题行 (Row + Text + Blank + Text)
│ │ ├→ 内容区 (ForEach / Grid / Scroll)
│ │ └→ 分割空白 (Blank().height)
│ └→ 底部留白 (Blank().height(100))
└→ Row() // 固定底部(首页导航栏)
└→ NavBtn × 5
首页有底部导航栏,用 Stack + Alignment.Bottom 把导航栏固定在底部。其他四个页面没有底部导航栏,直接用 Column + Scroll 就够了:
Column() // 页面容器
├→ 导航栏 Row // 固定顶部
└→ Scroll() // 可滚动内容
└→ Column() // 垂直排列
├→ 卡片/列表区域
└→ 底部留白
这个模板的规律:导航栏不参与滚动,始终固定在顶部。首页通过 Stack 实现固定,其他页面通过把导航栏放在 Scroll 外面实现固定。两种方式的视觉效果一样——用户滚动内容时,导航栏始终可见。
八、布局选择的决策树
需要展示内容?
├→ 内容能一屏放下?
│ ├→ 是 → Column(垂直排列)
│ └→ 否 → Scroll + Column(垂直滚动)
├→ 内容需要层叠?
│ ├→ 是 → Stack(装饰层 + 内容层)
│ └→ 否 → Row / Column(线性排列)
├→ 内容是等宽卡片?
│ ├→ 数量固定 → Grid(自动对齐)
│ └→ 数量不固定 → Scroll + Row(横向滚动)
└→ 内容需要固定头部/底部?
├→ 是 → Stack + Alignment.Bottom/Top
└→ 否 → 直接用 Column
实际开发中,大多数页面是这些布局的组合——Stack 包裹 Scroll,Scroll 包裹 Column,Column 里嵌套 Row 和 Grid。理解每种布局的"职责"(Stack=层叠、Row=横向、Column=纵向、Grid=网格、Scroll=溢出滚动),就能快速判断该用哪种组合。
更多推荐
所有评论(0)