写完五个页面后发现,所有 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=溢出滚动),就能快速判断该用哪种组合。

Logo

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

更多推荐