
📖 引言
上一篇我们讲了详情页的顶部头部------大图背景、民族名称、操作按钮,那些都是"门面"。
门面再好看,用户还是要往下看的。真正的内容,在下面的一张张卡片里。
详情页的内容区有四张卡片:
- 基本信息卡片:宗教、语系、文字、人口排行(2x2 网格)
- 语言文字卡片:使用语言、所属语系、文字系统
- 分布地区卡片:主要分布区 + 省份标签
- 详细介绍卡片:长文本介绍 + 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 张卡片里都是统一的。为什么要统一?
- 视觉一致性:所有卡片看起来是"一家子"的,不是东拼西凑的
- 开发效率:规范定好了,写每张卡片都照着来,不用每次都想"圆角多大来着"
- 维护方便:以后要改卡片样式,只要改一处规范,所有卡片都跟着变
💡 设计系统的雏形
你看,我们现在做的事情,其实就是在搭一个"设计系统"(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 的子项,就像 ListItem 是 List 的子项一样。
每个 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 有几个优势:
- 对齐更精准:Grid 的列是严格对齐的,Flex wrap 可能因为内容高度不一致而错位
- 性能更好:Grid 是专门的网格组件,渲染效率更高
- 功能更强 :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) // 左对齐
设计要点:
- 垂直排列:用 Column,上下两行
- 上小下大:标签(小字、浅色)在上,数值(大字、深色粗体)在下
- 左对齐 :
alignItems(HorizontalAlign.Start),所有内容靠左 - 数值最多两行 :
maxLines(2),太长了显示省略号,防止撑破布局 - 数值宽度 100%:占满 GridItem 的宽度
💡 为什么数值要 width('100%')?
因为 Text 默认宽度是"包裹内容"的,如果数值很短,Text 就很窄,
maxLines和textOverflow就没意义了。设成
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 图片展示静态图表
但对于「民族图鉴」来说,进度条+数字动画就够了,不用搞太复杂的图表。
💡 数据可视化的原则
- 准确:首先要保证数据是对的
- 直观:一眼就能看懂
- 简洁:不要为了炫技搞复杂的图表
- 适度:不是所有数据都要可视化,重要数据才可视化
数据可视化的目的是"帮助用户理解数据",而不是"显得很高级"。
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:超出后显示省略号
💡 折叠展开的注意事项
- 默认状态:是默认展开还是默认折叠?要看信息的重要性
- 动画过渡:展开收起要有动画,不要太生硬
- 状态记忆:(可选)用户展开过的,下次进来还是展开的
- 按钮位置:展开按钮要在文字下方,不要挡住文字
折叠展开是一种"渐进式披露"------先给用户看最重要的,想看更多再自己展开。既节省空间,又不影响深度用户。
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 整张卡片分享
还有一种更"高级"的方式:把整张卡片生成图片,用户可以保存或分享。
比如用户看到傣族的基础信息卡片,觉得不错,点"分享卡片",生成一张精美的卡片图片,可以直接发朋友圈。
这种方式的分享传播效果最好------图片比文字吸引人多了。
💡 复制分享的设计要点
- 入口明显:用户知道哪里可以复制分享
- 操作简单:点一下就能复制,不要复杂的流程
- 反馈及时:复制成功了要提示(比如弹个 Toast)
- 内容完整:分享出去的内容要完整,不要缺胳膊少腿
复制分享功能虽小,但用得好的话,能大大提升产品的传播力。
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 语音朗读功能
- 收藏功能与本地存储
- 民族地图页
- 测验页
- 个人中心页
- ......
但别着急,我们一步一步来。页面开发是个大工程,但只要掌握了"卡片"、"组件"、"复用"这些核心思想,再多的页面也不怕。
🔗 相关链接
- 项目源码 : GitCode 仓库
- Grid 组件 : 官方文档
- GridItem 组件 : 官方文档
- @Builder 装饰器 : 官方文档
- 自定义组件 : 官方文档
💡 提示:很多初学者写代码,喜欢"一气呵成"------一个 build 方法写几百行,什么都往里塞。写完当时觉得挺爽,但是过一个月再回来看,自己都看不懂了。更别说改需求了------牵一发而动全身,改一个地方要翻半天。
好的代码是什么样的?结构清晰、层次分明、各司其职。 就像搭积木------每个积木块有自己的功能,需要的时候拼在一起。以后要改,也只改某一块,不会影响其他的。
@Builder、自定义组件、封装...... 这些东西本质上都是在做同一件事:把大问题拆成小问题,每个小问题单独解决,然后再组合起来。 这是软件工程最核心的思想之一,也是从"会写代码"到"会设计"的必经之路。
不要急着写很多代码。先想一想:哪些地方是重复的?能不能抽出来?怎么组织更清晰?想清楚了再动手,代码质量会高很多。