HarmonyOS应用《民族图鉴》开发第20篇:民族详情页——基本信息卡片与网格布局深度解析

📖 引言

上一篇我们讲了详情页的顶部头部------大图背景、民族名称、操作按钮,那些都是"门面"。

门面再好看,用户还是要往下看的。真正的内容,在下面的一张张卡片里。

详情页的内容区有四张卡片:

  1. 基本信息卡片:宗教、语系、文字、人口排行(2x2 网格)
  2. 语言文字卡片:使用语言、所属语系、文字系统
  3. 分布地区卡片:主要分布区 + 省份标签
  4. 详细介绍卡片:长文本介绍 + TTS 语音朗读

这四张卡片,虽然内容各异,但结构上有很多共通之处:

  • 都是圆角矩形
  • 都有标题
  • 都有内边距
  • 宽度都是 90%(左右留白)

而且,基本信息卡片里的 4 个信息项,结构也是一样的:

  • 上面一行:图标 + 标签
  • 下面一行:数值

如果每个都写一遍,代码会重复很多。重复的代码,就意味着可以封装。

这一篇,我们就从基本信息卡片讲起,看看怎么用 Grid 网格布局 做 2x2 的信息展示,怎么用 @Builder 方法 把重复的信息项封装起来,以及怎么设计一套通用的卡片样式规范。

从这一篇开始,你会逐渐体会到"组件化"、"复用"这些思想的威力。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握卡片组件的通用设计模式(标题 + 内容 + 样式)
  • ✅ 学会使用 Grid 组件实现网格布局
  • ✅ 理解 @Builder 方法的使用场景与封装技巧
  • ✅ 掌握信息项组件的设计(图标 + 标签 + 数值)
  • ✅ 学会用组件化思想减少重复代码
  • ✅ 了解卡片的内边距、圆角、宽度规范
  • ✅ 能够设计出规范、美观、易维护的信息卡片

💡 需求分析

基本信息卡片的需求

需求点 说明 为什么重要
信息展示 展示 4 项核心信息:宗教、语系、文字、排行 用户快速了解关键信息
网格布局 2x2 网格,整齐美观 信息密度高,一目了然
统一结构 每个信息项结构一致(图标+标签+数值) 视觉规范,用户容易理解
卡片样式 圆角、内边距、白色背景 统一的视觉风格
响应式 在不同屏幕宽度下自适应 适配各种设备

信息项的结构拆解

每个信息项由三部分组成:

复制代码
┌───────────────────┐
│ 📝 宗教           │  ← 上一行:图标 + 标签(小字、浅色)
│ 佛教/道教/民间信仰│  ← 下一行:数值(大字、深色、粗体)
└───────────────────┘

结构很简单,但很实用。几乎所有的"信息展示"场景都可以用这种结构。

为什么用网格而不是列表?

4 项信息,如果用单列列表,要占 4 行,一屏看不完,用户得往下滑。

用 2x2 网格呢?

  • 信息密度高:同样的内容,占的高度少一半
  • 整齐美观:两两对齐,看起来很舒服
  • 符合阅读习惯:用户视线从左到右,从上到下,自然流畅

对于数量不多(4-6 个)、每个内容都不长的信息项,网格是最佳选择。


🎨 卡片设计规范

在写代码之前,我们先定一下卡片的"规矩"。详情页有 4 张卡片,如果每张都不一样,看起来会很乱。统一规范很重要。

卡片通用规范

属性 说明
宽度 90% 左右各留 5% 的边距,不贴边
背景色 card_background 用资源变量,适配暗色模式
圆角 radius_lg(16vp) 大圆角,现代感强
内边距 spacing_lg(16vp) 内容和边缘保持距离
标题字号 font_size_lg(18vp) 清晰醒目
标题字重 Bold 突出标题
标题颜色 text_primary 主文本色
卡片间距 spacing_lg(16vp) 卡片之间有呼吸感

这些规范在「民族图鉴」的 4 张卡片里都是统一的。为什么要统一?

  1. 视觉一致性:所有卡片看起来是"一家子"的,不是东拼西凑的
  2. 开发效率:规范定好了,写每张卡片都照着来,不用每次都想"圆角多大来着"
  3. 维护方便:以后要改卡片样式,只要改一处规范,所有卡片都跟着变

💡 设计系统的雏形

你看,我们现在做的事情,其实就是在搭一个"设计系统"(Design System)的雏形。

大公司的设计系统(比如 Ant Design、Material Design),本质上就是一整套这样的规范------颜色、字号、间距、圆角、组件样式...... 全都定好了,大家照着用。

小项目虽然不用搞那么复杂,但基本的规范意识要有。哪怕只定几个常用的数值,也比每次拍脑袋强。


🛠️ 核心实现

步骤1:卡片的通用结构

先不管里面放什么,先把卡片的"壳子"搭起来。

typescript 复制代码
@Builder
buildBasicInfoCard(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // 1. 卡片标题
    Text($r('app.string.detail_basic_info'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))

    // 2. 卡片内容(网格)
    Grid() {
      // ... 4 个 GridItem
    }
    .columnsTemplate('1fr 1fr')
    .rowsGap($r('app.float.spacing_sm'))
    .columnsGap($r('app.float.spacing_sm'))
  }
  .width('90%')
  .padding($r('app.float.spacing_lg'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

卡片结构 = Column 包裹(标题 + 内容)

  • 外层 Column:垂直排列标题和内容
  • space: spacing_md:标题和内容之间的间距
  • 样式属性:宽度 90%、内边距 16vp、圆角 16vp、白色背景
  • alignItems(HorizontalAlign.Start):内容左对齐(标题不要居中)

就这么简单。这是卡片的"通用骨架",换个标题和内容,就是另一张卡片。


步骤2:Grid 网格布局------2x2 是怎么实现的?

基本信息卡片里的 4 个信息项,排成 2 行 2 列,用的是 Grid 组件。

2.1 Grid 的基本用法
typescript 复制代码
Grid() {
  GridItem() { /* 第一项 */ }
  GridItem() { /* 第二项 */ }
  GridItem() { /* 第三项 */ }
  GridItem() { /* 第四项 */ }
}
.columnsTemplate('1fr 1fr')     // 两列,等宽
.rowsGap($r('app.float.spacing_sm'))      // 行间距
.columnsGap($r('app.float.spacing_sm'))   // 列间距
2.2 columnsTemplate 是什么?

columnsTemplate 用来定义有几列、每列多宽。

比如 '1fr 1fr' 就是两列,每列占 1 份,也就是等宽。

fr 是"分数单位"(fraction),和 CSS Grid 里的 fr 是一个意思。

几个例子:

columnsTemplate 效果
'1fr 1fr' 2 列,等宽
'1fr 1fr 1fr' 3 列,等宽
'2fr 1fr' 2 列,左边是右边的 2 倍宽
'100vp 1fr' 2 列,左边固定 100vp,右边占剩余

rowsTemplate 同理,用来定义行。但一般我们不定义行------有多少内容就有多少行,自动排。

2.3 GridItem 是什么?

GridItem 是 Grid 的子项,就像 ListItemList 的子项一样。

每个 GridItem 包裹一个网格单元的内容。

typescript 复制代码
GridItem() {
  this.buildInfoItem(
    '\u{1F4DD}',      // 图标
    '宗教',            // 标签
    this.ethnic!.religion  // 数值
  )
}
2.4 网格的排列顺序

Grid 默认的排列顺序是:从左到右,从上到下,一行填满了换下一行

复制代码
第1个 → 第2个
第3个 → 第4个
第5个 → 第6个
...

这也是最符合阅读习惯的顺序。

💡 Grid vs Row + Flex wrap

有人可能会问:用 Row + FlexWrap 不也能实现网格吗?为什么要用 Grid?

确实,用 Flex 布局也能做出网格的效果。但 Grid 有几个优势:

  1. 对齐更精准:Grid 的列是严格对齐的,Flex wrap 可能因为内容高度不一致而错位
  2. 性能更好:Grid 是专门的网格组件,渲染效率更高
  3. 功能更强 :Grid 支持跨行跨列(rowStart/rowEnd/columnStart/columnEnd),Flex 做不到
    当然,简单的场景 Flex 也够用。但既然 ArkUI 提供了 Grid,为什么不用呢?

步骤3:信息项的封装------@Builder 的威力

4 个信息项,结构都是一样的:图标 + 标签在上一行,数值在下一行。

如果每个都写一遍,就是 4 份几乎一样的代码。重复代码是"万恶之源"------改的时候要改 4 遍,容易漏,容易错。

怎么办?封装!

把相同的部分抽出来,做成一个可复用的"信息项组件",用的时候传参数就行。

在 ArkUI 里,最简单的封装方式就是 @Builder 方法

3.1 什么是 @Builder 方法?

@Builder 是 ArkUI 提供的一种轻量复用机制------把一段 UI 代码抽成一个方法,可以像调用函数一样调用它。

typescript 复制代码
@Builder
buildInfoItem(icon: string, label: string, value: string): void {
  Column({ space: $r('app.float.spacing_xs') }) {
    // 上一行:图标 + 标签
    Row({ space: 4 }) {
      Text(icon)
        .fontSize($r('app.float.icon_size_sm'))

      Text(label)
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
    }

    // 下一行:数值
    Text(value)
      .fontSize($r('app.float.font_size_sm'))
      .fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .maxLines(2)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
  }
  .alignItems(HorizontalAlign.Start)
}
3.2 怎么用?

调用的时候,就像调用普通方法一样:

typescript 复制代码
GridItem() {
  this.buildInfoItem(
    '\u{1F4DD}',      // 图标:📝
    '宗教',            // 标签
    this.ethnic!.religion  // 数值
  )
}
GridItem() {
  this.buildInfoItem(
    '\u{1F310}',      // 图标:🌐
    '语系',            // 标签
    this.getLocalizedText(
      this.ethnic!.languageFamily,
      this.ethnic!.languageFamilyEn
    )                  // 数值(支持多语言)
  )
}
GridItem() {
  this.buildInfoItem('\u{1FAAC}', '文字', this.ethnic!.script)
}
GridItem() {
  this.buildInfoItem('\u{1F3AF}', '排行', `#${this.ethnic!.populationRank}`)
}

看!代码是不是简洁多了?

每个信息项只要传 3 个参数:图标、标签、数值。4 个信息项,4 行调用,清晰明了。

3.3 信息项的结构详解

信息项虽然小,但"麻雀虽小,五脏俱全"。

typescript 复制代码
Column({ space: $r('app.float.spacing_xs') }) {
  // 第一行:图标 + 标签
  Row({ space: 4 }) {
    Text(icon)                    // 图标
      .fontSize($r('app.float.icon_size_sm'))

    Text(label)                   // 标签
      .fontSize($r('app.float.font_size_xs'))
      .fontColor($r('app.color.text_hint'))
  }

  // 第二行:数值
  Text(value)                     // 数值
    .fontSize($r('app.float.font_size_sm'))
    .fontWeight(FontWeight.Medium)
    .fontColor($r('app.color.text_primary'))
    .maxLines(2)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
    .width('100%')
}
.alignItems(HorizontalAlign.Start)  // 左对齐

设计要点

  1. 垂直排列:用 Column,上下两行
  2. 上小下大:标签(小字、浅色)在上,数值(大字、深色粗体)在下
  3. 左对齐alignItems(HorizontalAlign.Start),所有内容靠左
  4. 数值最多两行maxLines(2),太长了显示省略号,防止撑破布局
  5. 数值宽度 100%:占满 GridItem 的宽度

💡 为什么数值要 width('100%')?

因为 Text 默认宽度是"包裹内容"的,如果数值很短,Text 就很窄,maxLinestextOverflow 就没意义了。

设成 width('100%'),让数值占满整个网格单元,该换行换行,该省略省略。

3.4 @Builder 的好处
  • 减少重复代码:4 个信息项只写 1 份结构
  • 方便统一修改:以后要改信息项的样式,只改这一处,4 个都变
  • 代码更清晰:调用时只看参数就知道这一项是什么,不用看一堆样式代码
  • 可复用性强:别的地方要用类似的信息项,直接调用这个方法就行

这就是"组件化"思想的雏形------把重复的东西抽出来,复用再复用

💡 @Builder vs 自定义组件

你可能会问:为什么不做成一个独立的组件(单独的 struct)?

当然可以。但 @Builder 更轻量:

  • @Builder:适合页面内的小范围复用,写法简单,直接定义在页面里
  • 自定义组件 :适合跨页面复用,功能更完整,可以有自己的状态和生命周期
    对于信息项这种"小东西",用 @Builder 就够了,没必要单独拆一个组件文件。
    等以后多个页面都要用了,再抽成独立组件也不迟。不要过度设计。

步骤4:把卡片拼起来

现在卡片壳子有了,网格有了,信息项也有了。拼起来就是完整的基本信息卡片:

typescript 复制代码
@Builder
buildBasicInfoCard(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // 标题
    Text($r('app.string.detail_basic_info'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))

    // 2x2 信息网格
    Grid() {
      GridItem() {
        this.buildInfoItem(
          '\u{1F4DD}',
          '宗教',
          this.ethnic!.religion
        )
      }
      GridItem() {
        this.buildInfoItem(
          '\u{1F310}',
          '语系',
          this.getLocalizedText(
            this.ethnic!.languageFamily,
            this.ethnic!.languageFamilyEn
          )
        )
      }
      GridItem() {
        this.buildInfoItem(
          '\u{1FAAC}',
          '文字',
          this.ethnic!.script
        )
      }
      GridItem() {
        this.buildInfoItem(
          '\u{1F3AF}',
          '排行',
          `#${this.ethnic!.populationRank}`
        )
      }
    }
    .columnsTemplate('1fr 1fr')
    .rowsGap($r('app.float.spacing_sm'))
    .columnsGap($r('app.float.spacing_sm'))
  }
  .width('90%')
  .padding($r('app.float.spacing_lg'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

@Builder
buildInfoItem(icon: string, label: string, value: string): void {
  Column({ space: $r('app.float.spacing_xs') }) {
    Row({ space: 4 }) {
      Text(icon)
        .fontSize($r('app.float.icon_size_sm'))

      Text(label)
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
    }

    Text(value)
      .fontSize($r('app.float.font_size_sm'))
      .fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .maxLines(2)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
  }
  .alignItems(HorizontalAlign.Start)
}

看起来代码不少,但结构非常清晰:

  • buildBasicInfoCard:卡片的"外壳"
  • buildInfoItem:信息项的"模板"
  • 4 个 GridItem:4 个具体的信息项,调用模板传参数

步骤5:其他卡片------万变不离其宗

详情页有 4 张卡片,基本信息卡片只是第一张。其他三张(语言文字、分布地区、详细介绍)虽然内容不同,但"壳子"是一样的。

我们快速过一下,你会发现"卡片"这个模式真的很好用。

5.1 语言文字卡片
typescript 复制代码
@Builder
buildLanguageCard(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // 标题
    Text($r('app.string.detail_lang_script'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))

    // 第一行:使用语言 + 所属语系(左右布局)
    Row({ space: $r('app.float.spacing_md') }) {
      Column({ space: 6 }) {
        Text($r('app.string.detail_used_language'))
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_hint'))

        Text(this.getLocalizedText(this.ethnic!.language, this.ethnic!.languageEn))
          .fontSize($r('app.float.font_size_md'))
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.text_primary'))
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text($r('app.string.detail_belong_family'))
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_hint'))

        Text(this.getLocalizedText(this.ethnic!.languageFamily, this.ethnic!.languageFamilyEn))
          .fontSize($r('app.float.font_size_md'))
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.text_primary'))
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')

    // 第二行:文字系统(灰色背景条)
    Row() {
      Text('\u{1F4D6}')
        .fontSize($r('app.float.icon_size_sm'))

      Text($r('app.string.detail_writing_system'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))

      Blank()

      Text(this.ethnic!.script)
        .fontSize($r('app.float.font_size_sm'))
        .fontColor($r('app.color.text_secondary'))
    }
    .width('100%')
    .margin({ top: $r('app.float.spacing_sm') })
    .padding({ left: $r('app.float.spacing_sm'), right: $r('app.float.spacing_sm') })
    .height(32)
    .backgroundColor($r('app.color.background_color'))
    .borderRadius($r('app.float.radius_sm'))
    .alignItems(VerticalAlign.Center)
  }
  .width('90%')
  .padding($r('app.float.spacing_lg'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

你看,"壳子"一模一样------Column + space: spacing_md + 标题 + 宽度 90% + 内边距 + 圆角 + 背景色。

只是里面的内容不一样:

  • 基本信息卡片:里面是 Grid(2x2 网格)
  • 语言文字卡片:里面是 Row(左右分栏)+ Row(灰色背景条)
5.2 分布地区卡片
typescript 复制代码
@Builder
buildRegionCard(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // 标题
    Text($r('app.string.detail_distribution'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))

    // 主要分布区
    Row() {
      Text('\uD83C\uDF0D')
        .fontSize($r('app.float.icon_size_sm'))

      Text($r('app.string.detail_main_region'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))

      Blank()

      Text(this.ethnic!.region)
        .fontSize($r('app.float.font_size_sm'))
        .fontWeight(FontWeight.Medium)
        .fontColor($r('app.color.primary_color'))
    }
    .width('100%')

    // 省份标签(自动换行)
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(this.ethnic!.provinces as Array<string>, (province: string) => {
        Text(province)
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_secondary'))
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .margin({ top: 4, right: 6 })
          .borderRadius(12)
          .backgroundColor($r('app.color.background_color'))
      })
    }
    .width('100%')
    .margin({ top: $r('app.float.spacing_sm') })
  }
  .width('90%')
  .padding($r('app.float.spacing_lg'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

还是一样的"壳子",里面换成了"主要分布区 + 省份标签列表"。

省份标签用的是 Flex + FlexWrap.Wrap------标签一行排不下就自动换行。这也是很常用的布局方式。

5.3 详细介绍卡片
typescript 复制代码
@Builder
buildDescriptionCard(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // 标题行 + TTS按钮
    Row() {
      Text('\uD83D\uDCDC\u{FE0F}')
        .fontSize($r('app.float.icon_size_md'))

      Text($r('app.string.detail_introduction'))
        .fontSize($r('app.float.font_size_lg'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))

      Blank()

      // TTS播放按钮
      if (!this.isBasicMode) {
        Row({ space: 4 }) {
          Text(this.isPlaying ? '\u{23F8}' : '\u{25B6}')
            .fontSize($r('app.float.icon_size_sm'))

          Text(this.isPlaying
            ? $r('app.string.detail_btn_stop')
            : $r('app.string.detail_btn_read'))
            .fontSize($r('app.float.font_size_xs'))
            .fontColor(this.isPlaying
              ? $r('app.color.error_color')
              : $r('app.color.primary_color'))
        }
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(14)
        .backgroundColor(this.isPlaying
          ? $r('app.color.background_color')
          : '#E8F0FE')
        .onClick(() => {
          this.toggleTTS();
        })
      }
    }
    .width('100%')

    // 详细描述文本
    Text(this.getLocalizedText(this.ethnic!.description, this.ethnic!.descriptionEn))
      .fontSize($r('app.float.font_size_md'))
      .fontColor($r('app.color.text_secondary'))
      .lineHeight(24)
      .width('100%')

    // 英文描述(中文模式下显示英文作为补充)
    if (this.isChinese()) {
      Divider()
        .color($r('app.color.divider_color'))

      Text(this.ethnic!.descriptionEn)
        .fontSize($r('app.float.font_size_sm'))
        .fontColor($r('app.color.text_hint'))
        .lineHeight(20)
        .width('100%')
    }
  }
  .width('90%')
  .padding($r('app.float.spacing_lg'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

"壳子"还是一样的。只是标题行多了个 TTS 按钮,内容是长文本。

5.4 发现规律了吗?

4 张卡片,结构完全一样:

复制代码
Column({ space: spacing_md }) {
  // 标题
  Text("标题")
    .fontSize(lg)
    .fontWeight(Bold)
    .fontColor(text_primary)

  // 内容(每张卡片不一样)
  ...
}
.width('90%')
.padding(spacing_lg)
.backgroundColor(card_background)
.borderRadius(radius_lg)
.alignItems(HorizontalAlign.Start)

只有中间的"内容"部分不一样,其他全都是一样的!

这就是"模式"------找到重复的规律,然后用统一的方式去做。

💡 能不能再进一步封装?

既然 4 张卡片的"壳子"都一样,那能不能把"壳子"也封装起来?

当然可以!你可以做一个通用的 Card 组件,把标题和内容作为参数传进去。

但对于只有 4 张卡片的场景,目前的写法已经足够清晰了。要不要再封装,取决于你的项目规模和团队习惯。

记住:不要为了封装而封装。封装的目的是让代码更清晰、更好维护,而不是炫技。


🚀 进阶:从 @Builder 到自定义组件

@Builder 很好用,但它有局限性:

  • 只能在当前组件里用,别的页面用不了
  • 不能有自己的状态
  • 不能有自己的生命周期

如果你的信息项在很多页面都要用,那就该考虑做成自定义组件了。

自定义组件示例

typescript 复制代码
// components/InfoItem.ets

@Component
export struct InfoItem {
  icon: string = '';
  label: string = '';
  value: string = '';

  build() {
    Column({ space: 4 }) {
      Row({ space: 4 }) {
        Text(this.icon)
          .fontSize(14)

        Text(this.label)
          .fontSize(12)
          .fontColor($r('app.color.text_hint'))
      }

      Text(this.value)
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .fontColor($r('app.color.text_primary'))
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
  }
}

用的时候:

typescript 复制代码
import { InfoItem } from '../components/InfoItem';

// ...

GridItem() {
  InfoItem({
    icon: '\u{1F4DD}',
    label: '宗教',
    value: this.ethnic!.religion
  })
}

和 @Builder 用法很像,但是:

  • 可以在任何页面 import 了用
  • 可以有自己的 @State 变量
  • 可以有自己的生命周期

什么时候用 @Builder,什么时候用自定义组件?简单的、页面内复用的,用 @Builder;复杂的、跨页面复用的,用自定义组件。

这是一个渐进的过程------先用 @Builder,等发现"这个东西别的页面也要用"了,再抽成自定义组件也不迟。


🐛 常见问题与解决方案

问题1:网格内容高度不一致,错位了

现象:4 个信息项,有的内容多(2 行),有的内容少(1 行),导致网格单元高度不一样,看起来歪歪扭扭的。

解决方案

方案1:给 GridItem 固定高度(推荐)

typescript 复制代码
GridItem() {
  // ... 内容
}
.height(72)  // 固定高度

所有 GridItem 一样高,自然就对齐了。但要选一个合适的高度,确保最长的内容也能放下。

方案2:用 rowsTemplate 定义行高

typescript 复制代码
Grid() {
  // ...
}
.rowsTemplate('1fr 1fr')  // 两行,等高

这样每行的高度都是一样的,不会错位。

「民族图鉴」的基本信息卡片里,因为内容长度差不多,没有出现明显的错位问题。但如果你的内容差异很大,记得处理一下。


问题2:信息项数值太长,换行太多

现象:有的数值特别长(比如宗教信仰有好几个),在网格里占了两三行,把卡片撑得很高。

解决方案

方案1:限制行数 + 省略号

typescript 复制代码
Text(value)
  .maxLines(2)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

最多显示 2 行,超出就省略号。简洁干净。

方案2:用列表代替网格

如果内容普遍都很长,网格就不合适了。改成单列列表,每个信息项占一整行,空间更充足。

方案3:点击展开

默认显示 1-2 行,点击"展开"显示全部。交互复杂一点,但体验更好。


问题3:卡片太多,页面很长

现象:详情页卡片越来越多,用户得滑好久才能看完。

解决方案

方案1:折叠/展开

次要的卡片默认折叠起来,用户想看再点开。

方案2:Tab 切换

把内容分类,用 Tab 切换。比如"基本信息"、"文化习俗"、"历史渊源"三个 Tab。

方案3:精简内容

把最重要的信息留下,不重要的去掉,或者放到"更多"里。

「民族图鉴」目前只有 4 张卡片,长度刚刚好,不用做折叠。但如果以后内容越加越多,就要考虑这些方案了。


问题4:暗色模式下卡片不好看

现象:切到暗色模式,卡片还是浅色的,或者颜色不对。

解决方案:用资源系统定义颜色!

所有颜色都用 $r('app.color.xxx'),不要写死 #FFFFFF

然后在资源文件里定义两套:

  • base/element/color.json:浅色模式
  • dark/element/color.json:暗色模式

系统会自动根据当前模式选择颜色。

这是最规范、最省心的做法。从第一天就这么做,后面就不用返工了。


问题5:@Builder 方法太多,文件很乱

现象:页面里 @Builder 方法一大堆,找个方法要翻半天。

解决方案

方案1:按功能排序

把相关的 @Builder 方法放在一起。比如卡片相关的放前面,辅助方法放后面。

方案2:抽取到单独的文件

如果方法很多,可以抽成一个工具文件,用自定义组件的方式提供。

方案3:合理分层

页面只留页面级的布局,小组件都抽到 components/ 目录下。

一般来说,一个页面里有 3-5 个 @Builder 方法是正常的,不会太乱。如果超过 10 个,那就该考虑拆分了。


🧠 进阶拓展:信息卡片的深度设计

6.1 信息卡片的设计原则

信息卡片是详情页的"骨架"------所有内容都装在卡片里。信息卡片设计得好不好,直接影响整个页面的可读性。

6.1.1 清晰(Clarity)

原则:信息层级清晰,用户一眼就知道哪是标题、哪是内容。

怎么做?

  • 大小对比:标题大,内容小
  • 粗细对比:标题粗,内容细
  • 颜色对比:标题深,内容浅
  • 间距分层:不同组的信息间距不一样

反例:

  • 全都是一样大的字,分不清主次
  • 全都是一样的颜色,像 Word 文档
  • 间距都一样,不知道哪些是一组的

清晰的信息架构,用户扫一眼就能找到想看的内容。

6.1.2 易读(Readability)

原则:文字容易读,不费眼睛。

怎么做?

  • 行高合适:1.4-1.6 倍行高,读起来不累
  • 字重适中:正文不要太细也不要太粗
  • 颜色对比够:正文文字和背景的对比度至少 4.5:1
  • 长度合适:一行不要太长(最好 40-60 个字)
  • 不要全大写:全大写的英文很难读

💡 WCAG 对比度标准

  • 正文文字:对比度 ≥ 4.5:1
  • 大标题文字:对比度 ≥ 3:1
  • 装饰性文字:无所谓

可以用一些在线工具检测颜色对比度是否达标。

6.1.3 一致(Consistency)

原则:同样的信息用同样的方式展示,不要变来变去。

怎么做?

  • 同样的布局:所有卡片的内边距、圆角、间距都一样
  • 同样的信息项格式:图标+标签+值的格式,所有信息项都用一样的
  • 同样的标签颜色:标签文字都是灰色,值都是深色
  • 同样的图标风格:不要有的用线性图标,有的用填充图标

一致的设计,用户用起来就有"预期"------知道标签都是灰色的,知道值都是深色的,不用每次都重新学习。

💡 设计原则的优先级

清晰 > 易读 > 好看

很多新手设计师只追求"好看",忽略了清晰和易读。但信息卡片的核心目的是"传递信息",如果信息都看不明白,再好看也没用。

先保证清晰易读,再追求好看。


6.2 信息展示的多种形式

信息不只有"键值对"这一种展示方式。根据信息类型的不同,可以用不同的形式。

6.2.1 键值对形式(最常见)
复制代码
人口:约132万
分布:主要聚居在云南省西双版纳
语言:傣语

适用场景

  • 名称-值的成对信息
  • 数据型、事实型信息
  • 需要精确查找的信息

这是最常用、最清晰的形式。「民族图鉴」的基础信息、人口分布用的就是这种。

6.2.2 图标+文字形式
复制代码
👥 人口:约132万
📍 分布:云南西双版纳
🗣️ 语言:傣语

适用场景

  • 一眼识别的信息分类
  • 增加视觉趣味
  • 列表页的快捷信息

图标可以让信息更有辨识度------扫一眼图标就知道是什么类型的信息,不用读文字。

6.2.3 标签形式
复制代码
民族特色:  [傣族]  [泼水节]  [孔雀舞]  [竹楼]
传统节日:  [泼水节]  [关门节]  [开门节]

适用场景

  • 多值的分类信息
  • 标签、关键词
  • 可点击筛选的信息

标签形式适合展示"多个值"的信息,比如一个民族有很多特色、很多节日。

6.2.4 进度条形式
复制代码
人口数量:████████████░░░░ 65%
(在56个民族中排名第18)

适用场景

  • 百分比数据
  • 排名、排行
  • 进度、完成度

进度条让数据更直观------不用想"132万是多还是少",看一眼进度条就知道大概在什么位置。

6.2.5 「民族图鉴」的选择

「民族图鉴」的基础信息卡片,我们可以这样组合:

复制代码
┌─────────────────────────────────┐
│  基础信息                        │
│                                  │
│  📍 别名      傣泰民族           │
│  👥 人口      约132万            │
│  🗺️ 分布      云南西双版纳        │
│  🗣️ 语言      傣语               │
│  📅 节日      泼水节、关门节...   │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│  民族标签                        │
│                                  │
│  [傣族] [泼水节] [孔雀舞] [竹楼]  │
│  [热带] [东南亚] [小乘佛教]       │
└─────────────────────────────────┘
  • 基础信息用"图标+键值对",清晰好读
  • 特色标签用"标签"形式,一目了然
  • 数据型的信息可以加进度条,更直观

不同的信息用最合适的形式展示,比全用同一种形式生动多了。


6.3 人口数据的可视化

数据如果只是干巴巴的数字,用户很难有感觉。可视化让数据更直观、更有趣。

6.3.1 数字动画

数字从 0 慢慢增加到目标值,有"跳动"的感觉,比直接出现一个数字生动多了。

typescript 复制代码
@State population: number = 0;
private targetPopulation: number = 1320000;

aboutToAppear(): void {
  // 页面出现后,数字从 0 动画到目标值
  animateTo({
    duration: 1500,
    curve: Curve.EaseOut,
    onFinish: () => {}
  }, () => {
    this.population = this.targetPopulation;
  });
}

// 显示的时候格式化一下
Text(`${this.formatNumber(this.population)}人`)
  .fontSize(20)
  .fontWeight(FontWeight.Bold)

效果:页面一进来,数字"蹭蹭蹭"地往上涨,最后停在目标值。很有动感。

6.3.2 进度条对比

在56个民族中,这个民族的人口排第几?用进度条一眼就能看出来。

typescript 复制代码
// 进度条组件
Row() {
  // 进度条背景
  Stack({ alignContent: Alignment.Start }) {
    // 背景槽
    Rect()
      .width('100%')
      .height(8)
      .fill('#E0E0E0')
      .borderRadius(4)
    
    // 进度填充
    Rect()
      .width(`${this.populationPercent}%`)
      .height(8)
      .fill('#409EFF')
      .borderRadius(4)
  }
  .width('70%')
  
  // 排名文字
  Text(`第 ${this.populationRank} 位`)
    .fontSize(12)
    .fontColor('#999')
}
.width('100%')

用户一看进度条就知道:哦,这个民族人口大概在什么位置。比单纯说"第18位"直观多了。

6.3.3 图表展示

如果有更复杂的数据(比如人口变化趋势、分布比例),可以用图表。

鸿蒙系统没有内置图表库,但可以:

  • 自己用 Canvas 画简单的图表
  • 用第三方图表库(如果有的话)
  • 用 SVG 图片展示静态图表

但对于「民族图鉴」来说,进度条+数字动画就够了,不用搞太复杂的图表。

💡 数据可视化的原则

  1. 准确:首先要保证数据是对的
  2. 直观:一眼就能看懂
  3. 简洁:不要为了炫技搞复杂的图表
  4. 适度:不是所有数据都要可视化,重要数据才可视化

数据可视化的目的是"帮助用户理解数据",而不是"显得很高级"。


6.4 可折叠/展开的长信息

有的信息很长(比如民族简介),一次性全展开的话页面会很长。怎么办?做折叠展开!

6.4.1 什么时候用折叠展开?

适合折叠的信息:

  • 内容很长(超过 3 行)
  • 不是所有用户都想看
  • 辅助性、补充性的信息

不适合折叠的信息:

  • 核心信息、关键信息
  • 大部分用户都会看的
  • 内容很短(2-3行就能放下)

「民族图鉴」里,民族简介、文化习俗这种长文本,就适合做折叠展开。

6.4.2 两种折叠方式

方式1:行数截断 + "展开全文"按钮

默认只显示 3 行,后面有个"展开"按钮,点击后显示全部。

复制代码
傣族(罗马字母:Dai),又称泰族
(Shan),是一个跨境民族,在中
国、老挝、缅甸、泰国等国家都
...
[展开全文 ▼]

这种方式的好处是:不占地方,用户可以自己决定要不要展开。

方式2:Tab 切换

不同类型的信息放在不同的 Tab 里,点击切换。

复制代码
[简介] [文化] [习俗] [节日]

这种方式适合信息分类比较多的场景。

6.4.3 折叠展开的实现思路
typescript 复制代码
@State isExpanded: boolean = false;
private maxLines: number = 3;

// 简介卡片
Column() {
  Text('民族简介')
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
  
  // 简介内容
  Text(introduction)
    .fontSize(14)
    .fontColor('#666')
    .maxLines(this.isExpanded ? Infinity : this.maxLines)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
  
  // 展开/收起按钮
  Text(this.isExpanded ? '收起 ▲' : '展开全文 ▼')
    .fontSize(14)
    .fontColor('#409EFF')
    .onClick(() => {
      animateTo({ duration: 300 }, () => {
        this.isExpanded = !this.isExpanded;
      });
    })
}

关键属性:

  • maxLines:最多显示几行,Infinity 表示不限制
  • textOverflow:超出后显示省略号

💡 折叠展开的注意事项

  1. 默认状态:是默认展开还是默认折叠?要看信息的重要性
  2. 动画过渡:展开收起要有动画,不要太生硬
  3. 状态记忆:(可选)用户展开过的,下次进来还是展开的
  4. 按钮位置:展开按钮要在文字下方,不要挡住文字

折叠展开是一种"渐进式披露"------先给用户看最重要的,想看更多再自己展开。既节省空间,又不影响深度用户。


6.5 信息的复制与分享

用户看到有用的信息,想复制下来或者分享给朋友。这个功能虽小,但体验差别很大。

6.5.1 文字复制

最简单的方式:长按文字弹出复制选项。

在鸿蒙里,可以用 Text 组件的 copyOption 属性:

typescript 复制代码
Text('要复制的文字内容')
  .copyOption(CopyOptions.InApp)  // 应用内可复制

但这种方式只能复制整段文字。如果想更灵活,可以做一个"复制"按钮:

typescript 复制代码
Row() {
  Text('人口')
    .fontSize(14)
    .fontColor('#999')
  
  Blank()
  
  Text('约132万')
    .fontSize(14)
  
  // 复制按钮
  Image($r('app.media.ic_copy'))
    .width(16)
    .height(16)
    .onClick(() => {
      // 复制到剪贴板
      // 鸿蒙可以用 @ohos.pasteboard 模块
    })
}
6.5.2 单条信息分享

如果用户觉得某条信息很有意思,想分享出去怎么办?

可以做一个"长按弹出菜单"的交互:

  • 长按某条信息
  • 弹出菜单:复制 / 分享 / 收藏
  • 点击分享,调起系统分享面板
6.5.3 整张卡片分享

还有一种更"高级"的方式:把整张卡片生成图片,用户可以保存或分享。

比如用户看到傣族的基础信息卡片,觉得不错,点"分享卡片",生成一张精美的卡片图片,可以直接发朋友圈。

这种方式的分享传播效果最好------图片比文字吸引人多了。

💡 复制分享的设计要点

  1. 入口明显:用户知道哪里可以复制分享
  2. 操作简单:点一下就能复制,不要复杂的流程
  3. 反馈及时:复制成功了要提示(比如弹个 Toast)
  4. 内容完整:分享出去的内容要完整,不要缺胳膊少腿

复制分享功能虽小,但用得好的话,能大大提升产品的传播力。


6.6 「民族图鉴」基础信息的完整字段设计

最后,我们来规划一下「民族图鉴」的基础信息卡片,到底要放哪些字段。

6.6.1 字段分类

我们把民族的基础信息分成几类:

1. 基本信息

  • 中文名:汉族
  • 别名:华夏族、汉人
  • 人口:约12.86亿
  • 人口排名:第1位

2. 地理分布

  • 主要分布:全国各地
  • 主要聚居地:黄河流域、长江流域、珠江流域
  • 分布特点:大杂居、小聚居

3. 语言文字

  • 语言:汉语
  • 文字:汉字
  • 方言:官话、粤语、吴语、闽语等

4. 文化习俗

  • 传统节日:春节、元宵节、清明节、端午节、中秋节
  • 特色饮食:饺子、汤圆、粽子、月饼
  • 传统服饰:汉服
  • 宗教信仰:祖先崇拜、儒释道

5. 其他

  • 起源时间:约公元前5000年
  • 标志性建筑:长城、故宫、兵马俑
  • 文化符号:龙、凤、汉字、丝绸
6.6.2 信息优先级

这么多字段,不可能都放在最前面。我们按优先级排一下:

第一优先级(必看)

  • 中文名
  • 人口
  • 主要分布
  • 简介

第二优先级(重要)

  • 别名
  • 语言
  • 传统节日
  • 特色饮食

第三优先级(补充)

  • 文字
  • 传统服饰
  • 宗教信仰
  • 起源时间

用户一进来,先看到最核心的信息。感兴趣的话,往下滑能看到更多补充信息。这就是"渐进式"的信息架构。

6.6.3 卡片组织

最后,把这些信息组织成几张卡片:

复制代码
┌─────────────────────────┐
│  基础信息                │  ← 第一优先级
│  名称、人口、分布        │
└─────────────────────────┘
┌─────────────────────────┐
│  民族简介                │  ← 第一优先级(长文本,可折叠)
│  ...                    │
└─────────────────────────┘
┌─────────────────────────┐
│  文化习俗                │  ← 第二优先级
│  节日、饮食、服饰        │
└─────────────────────────┘
┌─────────────────────────┐
│  语言文字                │  ← 第三优先级
│  语言、文字、方言        │
└─────────────────────────┘

每张卡片一个主题,信息组织清晰,用户好找。

💡 信息架构的"漏斗模型"

上面放最核心、最精炼的信息 → 大部分用户看到这里就够了

中间放重要的详细信息 → 感兴趣的用户会继续往下看

下面放补充性的深度信息 → 深度用户能看到想了解的

这样不同需求的用户都能得到满足,又不会让轻度用户觉得信息过载。

好的信息架构,就是让用户"想用的功能都能找到,不想看的内容不打扰"。


📝 本章小结

核心知识点

本文深入讲解了基本信息卡片和网格布局,从设计规范到代码实现:

1. 卡片设计规范

  • 宽度 90%,左右留白
  • 统一圆角(radius_lg / 16vp)
  • 统---内边距(spacing_lg / 16vp)
  • 标题统一(大字、粗体、主文本色)
  • 卡片间距一致(spacing_lg / 16vp)
  • 规范的意义:一致性、效率、易维护

2. Grid 网格布局

  • columnsTemplate 定义列数和宽度
  • fr 分数单位,等分布局用 '1fr 1fr'
  • GridItem 包裹每个网格单元
  • rowsGap / columnsGap 控制间距
  • 排列顺序:从左到右,从上到下

3. @Builder 方法封装

  • 把重复的 UI 抽成方法
  • 调用时传参数,灵活复用
  • 减少重复代码,方便统一修改
  • 适合页面内的小范围复用

4. 信息项设计

  • 结构:图标 + 标签(上),数值(下)
  • 上小下大,上浅下深
  • 左对齐,数值最多 2 行
  • 简洁、通用、可复用

5. 卡片的通用模式

  • 外层 Column + space
  • 标题 Text
  • 内容区域(各有不同)
  • 统---的宽度、内边距、圆角、背景色
  • 4 张卡片,一个模式,万变不离其宗

6. 进阶:自定义组件

  • 跨页面复用时,从 @Builder 升级为自定义组件
  • 自定义组件可以有自己的状态和生命周期
  • 渐进式:先用 @Builder,需要时再抽组件

最佳实践总结

卡片通用骨架:Column + 标题 + 内容

typescript 复制代码
Column({ space: spacing_md }) {
  Text("标题")
    .fontSize(lg)
    .fontWeight(Bold)
  // 内容
}
.width('90%')
.padding(spacing_lg)
.backgroundColor(card_background)
.borderRadius(radius_lg)
.alignItems(HorizontalAlign.Start)

网格布局:Grid + columnsTemplate + GridItem

typescript 复制代码
Grid() {
  GridItem() { /* 内容1 */ }
  GridItem() { /* 内容2 */ }
  GridItem() { /* 内容3 */ }
  GridItem() { /* 内容4 */ }
}
.columnsTemplate('1fr 1fr')
.rowsGap(spacing_sm)
.columnsGap(spacing_sm)

信息项:图标+标签 + 数值

typescript 复制代码
Column({ space: xs }) {
  Row({ space: 4 }) {
    Text(icon)
    Text(label)
      .fontSize(xs)
      .fontColor(text_hint)
  }
  Text(value)
    .fontSize(sm)
    .fontWeight(Medium)
    .fontColor(text_primary)
    .maxLines(2)
    .width('100%')
}
.alignItems(HorizontalAlign.Start)

重复代码要封装,从 @Builder 开始

typescript 复制代码
// 定义
@Builder
buildInfoItem(icon: string, label: string, value: string) {
  // ...
}

// 使用
this.buildInfoItem('📝', '宗教', '佛教')

不要过度设计,渐进式封装

复制代码
复制粘贴 → @Builder 方法 → 自定义组件 → 组件库
    ↓          ↓             ↓           ↓
  快速实现   页面内复用    跨页复用     跨项目复用

下一篇预告

页面开发篇到这里,我们已经讲了:

  • 启动页动画
  • 首页 Tabs 框架
  • 首页搜索、冷知识卡片、横向滚动、快捷入口
  • 民族列表页(搜索、筛选、双视图)
  • 列表项卡片设计
  • 详情页顶部背景
  • 详情页基本信息卡片

页面开发篇还剩 10 篇(第 21-30 篇),接下来我们会讲:

  • 详情页的语言文字、分布地区、详细介绍卡片
  • TTS 语音朗读功能
  • 收藏功能与本地存储
  • 民族地图页
  • 测验页
  • 个人中心页
  • ......

但别着急,我们一步一步来。页面开发是个大工程,但只要掌握了"卡片"、"组件"、"复用"这些核心思想,再多的页面也不怕。


🔗 相关链接


💡 提示:很多初学者写代码,喜欢"一气呵成"------一个 build 方法写几百行,什么都往里塞。写完当时觉得挺爽,但是过一个月再回来看,自己都看不懂了。更别说改需求了------牵一发而动全身,改一个地方要翻半天。

好的代码是什么样的?结构清晰、层次分明、各司其职。 就像搭积木------每个积木块有自己的功能,需要的时候拼在一起。以后要改,也只改某一块,不会影响其他的。

@Builder、自定义组件、封装...... 这些东西本质上都是在做同一件事:把大问题拆成小问题,每个小问题单独解决,然后再组合起来。 这是软件工程最核心的思想之一,也是从"会写代码"到"会设计"的必经之路。

不要急着写很多代码。先想一想:哪些地方是重复的?能不能抽出来?怎么组织更清晰?想清楚了再动手,代码质量会高很多。

相关推荐
木木子2210 小时前
# 待办事项应用深度解析:ForEach 列表渲染与 CRUD 操作实战
windows·华为·harmonyos
2501_9437823512 小时前
【共创季稿事节】摇骰子:用 ArkTS 实现随机动画与交互反馈
运维·nginx·交互·harmonyos·鸿蒙·鸿蒙系统
zjxcq52012 小时前
【共创季稿事节】鸿蒙原生ArkTS布局之道——layoutWeight权重分配机制深度解析
华为·harmonyos
2501_9437823515 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
想你依然心痛15 小时前
AtomCode 在 HarmonyOS 开发环境中的表现测评
跨平台·harmonyos·arkts·信创·国产系统
2501_9437823516 小时前
【共创季稿事节】 倒计时器:时分秒选择器与定时器的协同工作
前端·华为·harmonyos·鸿蒙·鸿蒙系统
TrisighT16 小时前
Electron 鸿蒙 PC 上做本地搜索,Fuse.js 比 SQLite 快 6 倍——但我愣是选了最慢的方案
electron·sqlite·harmonyos
独守一片天16 小时前
HarmonyOS 6.1.0 Call Service 来电识别与安全通信怎么设计?
安全·华为·harmonyos
AI创界者17 小时前
【硬核教程】鸿蒙 HarmonyOS 4.2 / 4.3 完美配置 GMS 运行环境(纯净版/不弹窗/全机型通用)
华为·harmonyos
2501_9423895520 小时前
小米寥寥几家车企设计汽车顶棚
华为·编辑器·时序数据库·harmonyos