HarmonyOS APP<<古今职鉴定>>开源教程第6篇:布局与样式:打造精美界面
本篇深入学习 Flex 布局、样式复用机制,设计职官词典卡片组件
·
本篇深入学习 Flex 布局、样式复用机制,设计职官词典卡片组件
图:古今职鉴开源教程封面。本篇围绕「布局与样式:打造精美界面」展开。
学习目标
完成本篇后,你将能够:
- ✅ 掌握 Flex 布局的高级用法
- ✅ 使用 @Styles 复用通用样式
- ✅ 使用 @Extend 扩展组件样式
- ✅ 掌握 @Builder 构建函数的正确用法
- ✅ 设计可复用的卡片组件
预计学习时间
约 90 分钟
---
实战一:理解 Flex 布局
第一步:创建 lesson06 目录和文件
在 products/jiaocheng/src/main/ets/ 下创建 lesson06 文件夹,新建 Lesson06Page.ets:
// 文件路径:products/jiaocheng/src/main/ets/lesson06/Lesson06Page.ets
@Entry
@Component
struct Lesson06Page {
build() {
Column() {
Text('第6课:布局与样式')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
}
第二步:学习 justifyContent 主轴对齐
在 build() 中添加对比示例:
build() {
Column({ space: 16 }) {
Text('justifyContent 主轴对齐')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.padding(16)
// Start:靠左对齐
Column() {
Text('FlexAlign.Start')
.fontSize(12)
.fontColor('#64748b')
Flex({ justifyContent: FlexAlign.Start }) {
this.Box('#c41e3a')
this.Box('#4169e1')
this.Box('#228b22')
}
.width('100%')
.height(50)
.backgroundColor('#f0f0f0')
.borderRadius(8)
}
.padding({ left: 16, right: 16 })
// SpaceBetween:两端对齐
Column() {
Text('FlexAlign.SpaceBetween')
.fontSize(12)
.fontColor('#64748b')
Flex({ justifyContent: FlexAlign.SpaceBetween }) {
this.Box('#c41e3a')
this.Box('#4169e1')
this.Box('#228b22')
}
.width('100%')
.height(50)
.backgroundColor('#f0f0f0')
.borderRadius(8)
}
.padding({ left: 16, right: 16 })
// SpaceEvenly:等间距
Column() {
Text('FlexAlign.SpaceEvenly')
.fontSize(12)
.fontColor('#64748b')
Flex({ justifyContent: FlexAlign.SpaceEvenly }) {
this.Box('#c41e3a')
this.Box('#4169e1')
this.Box('#228b22')
}
.width('100%')
.height(50)
.backgroundColor('#f0f0f0')
.borderRadius(8)
}
.padding({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
@Builder
Box(color: string) {
Column()
.width(40)
.height(40)
.backgroundColor(color)
.borderRadius(4)
}
第三步:理解 justifyContent 的 6 种对齐方式
| 值 | 效果 | 适用场景 |
|---|---|---|
FlexAlign.Start |
靠起始端 | 默认左对齐 |
FlexAlign.Center |
居中 | 内容居中 |
FlexAlign.End |
靠结束端 | 右对齐 |
FlexAlign.SpaceBetween |
两端对齐,中间等分 | 导航栏、工具栏 |
FlexAlign.SpaceAround |
每个元素两侧等距 | 均匀分布 |
FlexAlign.SpaceEvenly |
所有间距相等 | 完美等分 |
第四步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 三行示例展示不同的对齐方式
- Start 靠左,SpaceBetween 两端对齐,SpaceEvenly 等间距
---
实战二:layoutWeight 权重布局
第一步:理解权重分配原理
layoutWeight 用于分配剩余空间。计算方式:
- 先分配固定宽度的元素
- 剩余空间按权重比例分配
第二步:添加权重布局示例
在 build() 中添加:
// 权重布局示例
Column() {
Text('layoutWeight 权重布局')
.fontSize(14)
.fontColor('#64748b')
.margin({ bottom: 8 })
Row() {
// 固定宽度 80vp
Text('固定80')
.width(80)
.height(40)
.backgroundColor('#c41e3a')
.fontColor(Color.White)
.textAlign(TextAlign.Center)
// 权重1:占剩余空间的 1/3
Text('权重1')
.layoutWeight(1)
.height(40)
.backgroundColor('#4169e1')
.fontColor(Color.White)
.textAlign(TextAlign.Center)
// 权重2:占剩余空间的 2/3
Text('权重2')
.layoutWeight(2)
.height(40)
.backgroundColor('#228b22')
.fontColor(Color.White)
.textAlign(TextAlign.Center)
}
.width('100%')
}
.padding(16)
第三步:理解计算过程
假设屏幕宽度 360vp,padding 各 16vp:
- 可用宽度 = 360 - 32 = 328vp
- 固定元素 = 80vp
- 剩余空间 = 328 - 80 = 248vp
- 权重1 = 248 × (1/3) ≈ 83vp
- 权重2 = 248 × (2/3) ≈ 165vp
第四步:运行验证
hvigorw assembleHap --no-daemon
预期效果:
- 红色块固定宽度
- 蓝色块占剩余空间的 1/3
- 绿色块占剩余空间的 2/3
---
实战三:@Styles 通用样式复用
第一步:理解 @Styles 的作用
@Styles 用于定义可复用的通用样式,适用于所有组件。
第二步:在组件内定义 @Styles
在 Lesson06Page 组件内添加:
@Entry
@Component
struct Lesson06Page {
// 定义卡片样式
@Styles
cardStyle() {
.backgroundColor(Color.White)
.borderRadius(12)
.padding(16)
.shadow({
radius: 4,
color: 'rgba(0, 0, 0, 0.05)',
offsetX: 0,
offsetY: 2
})
}
build() {
Column({ space: 16 }) {
Text('@Styles 样式复用')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.padding(16)
// 使用 cardStyle
Column() {
Text('卡片1')
.fontSize(16)
Text('这是第一个卡片的内容')
.fontSize(14)
.fontColor('#64748b')
}
.width('100%')
.cardStyle() // 应用样式
.margin({ left: 16, right: 16 })
// 复用相同样式
Column() {
Text('卡片2')
.fontSize(16)
Text('这是第二个卡片的内容')
.fontSize(14)
.fontColor('#64748b')
}
.width('100%')
.cardStyle() // 复用样式
.margin({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
}
第三步:理解 @Styles 的特点
| 特点 | 说明 |
|---|---|
| 适用范围 | 所有组件通用 |
| 定义位置 | 组件内或全局 |
| 参数支持 | ❌ 不支持参数 |
| 组件特有属性 | ❌ 不能使用(如 fontSize) |
第四步:运行查看效果
hvigorw assembleHap --no-daemon
预期效果:
- 两个卡片有相同的白色背景、圆角、阴影
- 样式代码只写一次,复用两次
---
实战四:@Extend 组件扩展样式
第一步:理解 @Extend 的作用
@Extend 用于扩展特定组件的样式,可以使用该组件的特有属性。
第二步:在文件顶部定义 @Extend
在组件外部(文件顶部)添加:
// 扩展 Text 组件 - 标题样式
@Extend(Text)
function titleText() {
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
}
// 扩展 Text 组件 - 描述样式
@Extend(Text)
function descText() {
.fontSize(13)
.fontColor('#64748b')
}
// 扩展 Text 组件 - 带参数的样式
@Extend(Text)
function coloredText(color: string) {
.fontSize(14)
.fontColor(color)
}
第三步:使用 @Extend 样式
build() {
Column({ space: 16 }) {
Text('@Extend 组件扩展样式')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.padding(16)
Column({ space: 8 }) {
// 使用 titleText 样式
Text('这是标题')
.titleText()
// 使用 descText 样式
Text('这是描述文字,使用了扩展样式')
.descText()
// 使用带参数的样式
Text('红色文字')
.coloredText('#c41e3a')
Text('蓝色文字')
.coloredText('#4169e1')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
.margin({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
第四步:对比 @Styles 和 @Extend
| 特性 | @Styles | @Extend |
|---|---|---|
| 适用范围 | 所有组件 | 特定组件 |
| 定义位置 | 组件内或全局 | 只能全局 |
| 参数支持 | ❌ | ✅ |
| 组件特有属性 | ❌ | ✅ |
选择建议:
- 通用样式(背景、圆角、阴影)→
@Styles - 组件特有样式(字体、按钮)→
@Extend
第五步:运行验证
hvigorw assembleHap --no-daemon
---
实战五:@Builder 构建函数
第一步:理解 @Builder 的作用
@Builder 用于定义可复用的 UI 片段,类似于"子组件"但更轻量。
第二步:创建基础 @Builder
@Entry
@Component
struct Lesson06Page {
// 定义 Builder
@Builder
SectionTitle(title: string) {
Row() {
Column()
.width(4)
.height(16)
.backgroundColor('#c41e3a')
.borderRadius(2)
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 16, top: 16, bottom: 8 })
}
build() {
Column() {
// 复用 Builder
this.SectionTitle('第一部分')
Text('第一部分的内容...')
.padding({ left: 16 })
this.SectionTitle('第二部分')
Text('第二部分的内容...')
.padding({ left: 16 })
this.SectionTitle('第三部分')
Text('第三部分的内容...')
.padding({ left: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
}
第三步:⚠️ 理解 @Builder 的限制
重要规则:@Builder 中不能使用 const/let 声明变量!
// ❌ 错误写法
@Builder
WrongBuilder(type: string) {
const color = type === 'A' ? '#c41e3a' : '#4169e1'; // 编译错误!
Text('内容')
.fontColor(color)
}
// ✅ 正确写法1:直接使用三元表达式
@Builder
CorrectBuilder1(type: string) {
Text('内容')
.fontColor(type === 'A' ? '#c41e3a' : '#4169e1')
}
// ✅ 正确写法2:使用方法
getColor(type: string): string {
return type === 'A' ? '#c41e3a' : '#4169e1';
}
@Builder
CorrectBuilder2(type: string) {
Text('内容')
.fontColor(this.getColor(type))
}
第四步:运行验证
hvigorw assembleHap --no-daemon
---
实战六:综合实战 - 职官卡片组件
第一步:定义数据接口
在文件顶部添加:
interface PositionInfo {
id: number;
name: string;
dynasty: string;
rank: number;
category: string; // '文官' | '武官'
description: string;
}
第二步:定义全局扩展样式
@Extend(Text)
function titleStyle() {
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
}
@Extend(Text)
function descStyle() {
.fontSize(13)
.fontColor('#64748b')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
第三步:创建完整页面
@Entry
@Component
struct Lesson06Page {
// 卡片样式
@Styles
cardStyle() {
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({
radius: 4,
color: 'rgba(0, 0, 0, 0.05)',
offsetX: 0,
offsetY: 2
})
}
// 测试数据
private positions: PositionInfo[] = [
{ id: 1, name: '丞相', dynasty: '秦', rank: 1, category: '文官', description: '百官之长,辅佐皇帝处理政务' },
{ id: 2, name: '太尉', dynasty: '秦', rank: 1, category: '武官', description: '掌管全国军事' },
{ id: 3, name: '御史大夫', dynasty: '秦', rank: 2, category: '文官', description: '监察百官,掌管图籍' },
{ id: 4, name: '大司马', dynasty: '汉', rank: 1, category: '武官', description: '最高军事长官' },
{ id: 5, name: '尚书令', dynasty: '唐', rank: 2, category: '文官', description: '尚书省长官' }
];
build() {
Column() {
this.PageHeader()
List() {
ForEach(this.positions, (item: PositionInfo) => {
ListItem() {
this.PositionCard(item)
}
}, (item: PositionInfo) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
@Builder
PageHeader() {
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.fillColor('#1e293b')
Text('职官词典')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
.margin({ left: 12 })
Blank()
Image($r('app.media.ic_search'))
.width(24)
.height(24)
.fillColor('#1e293b')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
@Builder
PositionCard(item: PositionInfo) {
Row() {
// 左侧品级标识
Column() {
Text(`${item.rank}品`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(item.category)
.fontSize(10)
.fontColor('rgba(255, 255, 255, 0.8)')
.margin({ top: 4 })
}
.width(50)
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(item.category === '文官' ? '#c41e3a' : '#4169e1')
.borderRadius(8)
// 中间信息
Column() {
Row() {
Text(item.name)
.titleStyle()
Text(item.dynasty)
.fontSize(12)
.fontColor('#64748b')
.backgroundColor('#f0f0f0')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ left: 8 })
}
Text(item.description)
.descStyle()
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
// 右侧箭头
Image($r('app.media.ic_chevron_right'))
.width(20)
.height(20)
.fillColor('#cccccc')
}
.width('100%')
.padding(16)
.margin({ bottom: 12 })
.cardStyle()
.onClick(() => {
console.log(`点击了:${item.name}`);
})
}
}
@Builder
export function Lesson06PageBuilder() {
Lesson06Page()
}
第四步:运行验证
hvigorw assembleHap --no-daemon
预期效果:
- 顶部显示页面标题栏
- 列表展示 5 个官职卡片
- 文官显示红色标识,武官显示蓝色标识
- 卡片有圆角和阴影
- 点击卡片控制台输出日志
---
完整代码
// 文件路径:products/jiaocheng/src/main/ets/lesson06/Lesson06Page.ets
// ----- 数据接口 -----
interface PositionInfo {
id: number;
name: string;
dynasty: string;
rank: number;
category: string;
description: string;
}
// ----- 全局扩展样式 -----
@Extend(Text)
function titleStyle() {
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1e293b')
}
@Extend(Text)
function descStyle() {
.fontSize(13)
.fontColor('#64748b')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
@Entry
@Component
struct Lesson06Page {
@Styles
cardStyle() {
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({
radius: 4,
color: 'rgba(0, 0, 0, 0.05)',
offsetX: 0,
offsetY: 2
})
}
private positions: PositionInfo[] = [
{ id: 1, name: '丞相', dynasty: '秦', rank: 1, category: '文官', description: '百官之长,辅佐皇帝处理政务' },
{ id: 2, name: '太尉', dynasty: '秦', rank: 1, category: '武官', description: '掌管全国军事' },
{ id: 3, name: '御史大夫', dynasty: '秦', rank: 2, category: '文官', description: '监察百官,掌管图籍' },
{ id: 4, name: '大司马', dynasty: '汉', rank: 1, category: '武官', description: '最高军事长官' },
{ id: 5, name: '尚书令', dynasty: '唐', rank: 2, category: '文官', description: '尚书省长官' }
];
build() {
Column() {
this.PageHeader()
List() {
ForEach(this.positions, (item: PositionInfo) => {
ListItem() {
this.PositionCard(item)
}
}, (item: PositionInfo) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#f8f6f5')
}
@Builder
PageHeader() {
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.fillColor('#1e293b')
Text('职官词典')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1e293b')
.margin({ left: 12 })
Blank()
Image($r('app.media.ic_search'))
.width(24)
.height(24)
.fillColor('#1e293b')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
@Builder
PositionCard(item: PositionInfo) {
Row() {
Column() {
Text(`${item.rank}品`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(item.category)
.fontSize(10)
.fontColor('rgba(255, 255, 255, 0.8)')
.margin({ top: 4 })
}
.width(50)
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(item.category === '文官' ? '#c41e3a' : '#4169e1')
.borderRadius(8)
Column() {
Row() {
Text(item.name)
.titleStyle()
Text(item.dynasty)
.fontSize(12)
.fontColor('#64748b')
.backgroundColor('#f0f0f0')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ left: 8 })
}
Text(item.description)
.descStyle()
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Image($r('app.media.ic_chevron_right'))
.width(20)
.height(20)
.fillColor('#cccccc')
}
.width('100%')
.padding(16)
.margin({ bottom: 12 })
.cardStyle()
.onClick(() => {
console.log(`点击了:${item.name}`);
})
}
}
@Builder
export function Lesson06PageBuilder() {
Lesson06Page()
}
---
本课小结
核心知识点
| 知识点 | 说明 |
|---|---|
| justifyContent | 主轴对齐:Start/Center/End/SpaceBetween/SpaceEvenly |
| alignItems | 交叉轴对齐:Start/Center/End/Stretch |
| layoutWeight | 权重布局,分配剩余空间 |
| @Styles | 通用样式复用,不支持参数 |
| @Extend | 组件扩展样式,支持参数 |
| @Builder | UI 片段复用,不能用 const/let |
@Builder 限制速记
// ❌ 错误:不能声明变量
@Builder Wrong() {
const x = 1; // 编译错误
}
// ✅ 正确:直接使用表达式
@Builder Correct(type: string) {
Text('内容')
.fontColor(type === 'A' ? 'red' : 'blue')
}
---
课后练习
练习1:实现三列等宽布局
使用 layoutWeight 实现三列等宽:
Row() {
Text('列1').layoutWeight(1)
Text('列2').layoutWeight(1)
Text('列3').layoutWeight(1)
}
练习2:创建带参数的 @Extend
@Extend(Column)
function themedCard(borderColor: string) {
.backgroundColor(Color.White)
.borderRadius(12)
.border({ width: 2, color: borderColor })
}
---
下一课预告
第7课我们将学习主题适配,包括:
- 系统主题监听机制
- AppStorage 全局状态管理
- 深色/浅色模式切换
- WCAG 色彩对比度规范
项目开源地址
更多推荐

所有评论(0)