本篇深入学习 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 用于分配剩余空间。计算方式:

  1. 先分配固定宽度的元素
  2. 剩余空间按权重比例分配

第二步:添加权重布局示例

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 色彩对比度规范

项目开源地址

https://gitcode.com/daleishen/gujinzhijian

Logo

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

更多推荐