
📖 引言
如果说列表页是应用的"货架",那详情页就是应用的"商品详情"------用户真正花时间阅读、消费内容的地方。
56 个民族,每个民族都有自己的故事。怎么把这些故事讲好?怎么让用户愿意停下来、读下去?这就是详情页要解决的问题。
「民族图鉴」的详情页,从上到下是这样的:
- 顶部头部区域:大图背景 + 民族名称 + 操作按钮(返回/收藏/分享/播放)
- 基本信息卡片:宗教、语系、文字、人口排行,2x2 网格
- 语言文字卡片:使用语言、所属语系、文字系统
- 分布地区卡片:主要分布区 + 省份标签
- 详细介绍卡片:长文本介绍 + TTS 语音朗读
信息很多,但层次分明。用户一眼扫过去,就知道"哪里有什么"。
这一篇,我们先聚焦在顶部头部区域。别看它只有 180vp 高,里面的学问可大了:背景图怎么加、遮罩层怎么做、文字在图片上怎么保证可读性、操作按钮怎么布局、Stack 堆叠布局怎么用......
把顶部做好了,详情页就成功了一半。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握详情页的整体信息架构设计方法
- ✅ 学会使用 Stack 堆叠布局实现层叠效果
- ✅ 掌握 rawfile 图片的引用方式($rawfile())
- ✅ 理解半透明遮罩层的作用与实现
- ✅ 学会在图片背景上保证文字可读性
- ✅ 掌握顶部操作栏的布局设计(返回/收藏/分享/播放)
- ✅ 理解错误状态的处理方式
- ✅ 能够设计出视觉效果好、信息清晰的详情页头部
💡 需求分析
详情页的核心需求
| 需求点 | 说明 | 为什么重要 |
|---|---|---|
| 信息展示 | 完整展示民族的所有信息 | 详情页的核心价值 |
| 视觉效果 | 顶部大图,有冲击力 | 第一印象,吸引用户 |
| 操作功能 | 返回、收藏、分享、语音朗读 | 基础交互功能 |
| 信息层级 | 主次分明,重点突出 | 让用户快速找到想看的 |
| 阅读体验 | 长文本排版舒适 | 用户愿意读下去 |
| 加载状态 | 数据加载中/失败有提示 | 不让用户困惑 |
顶部头部区域的需求拆解
顶部区域虽然不大,但"麻雀虽小,五脏俱全":
┌─────────────────────────────────────┐
│ ← ♡ 📨 ▶/⏸ │ ← 操作栏:返回、收藏、分享、播放
│ │
│ │
│ 汉族 │ ← 民族名称(大字、粗体)
│ 别名:华夏族、汉人 │ ← 别名(小字、浅色)
│ 人口:约12亿人 · #1 │ ← 人口 + 排名
│ │
└─────────────────────────────────────┘
↑ 背景图 + 半透明遮罩
三层结构:
- 底层:背景图片(铺满整个头部)
- 中层:半透明遮罩(保证文字可读)
- 顶层:文字和按钮(用户实际看到的内容)
🎨 设计理念:为什么用大图背景?
在讲代码之前,先聊聊设计。为什么很多 App 的详情页顶部都喜欢用大图?
1. 视觉冲击力
一张好的大图,比十行文字更有感染力。用户点进来,第一眼看到的就是这张图------图好看,用户就愿意往下滑。
「民族图鉴」每个民族都有自己的封面图,展示该民族的传统服饰、建筑或文化符号。用户一眼就能感受到这个民族的特色。
2. 氛围感
图片能营造氛围。藏族的布达拉宫、蒙古族的草原、傣族的竹楼...... 不同的图片带来不同的感觉,让每个民族的详情页都有自己的"性格"。
3. 信息分层
顶部大图是"视觉重区",下面的文字内容是"信息区"。两者形成鲜明对比,用户很自然地就知道:上面是"门面",下面才是"干货"。
但是!大图背景有个大问题
图片上的文字看不清!
图片颜色深浅不一,白色文字放在浅色区域就看不见了,黑色文字放在深色区域也看不见。
怎么解决?------ 加遮罩层。
在图片和文字之间,加一层半透明的黑色(或主题色)遮罩。这样图片还能看得见,但整体色调变暗了,白色文字就能清晰地显示在上面。
这是详情页顶部最经典、最实用的做法。几乎所有带大图头部的 App 都是这么做的。
💡 遮罩层的透明度多少合适?
没有标准答案,取决于你的图片和文字颜色。
一般来说,黑色遮罩 0.3-0.5 的透明度比较常用。
「民族图鉴」用的是民族主题色 + 0.3 透明度,既保证了文字可读性,又能体现民族特色。
🛠️ 核心实现
步骤1:整体架构
详情页的整体结构很清晰:头部 + 滚动内容区。
typescript
// pages/EthnicDetailPage.ets
@Entry
@Component
struct EthnicDetailPage {
@State ethnic: EthnicGroup | undefined = undefined;
build() {
Column() {
if (!this.ethnic) {
// 加载失败/未找到
this.buildErrorView()
} else {
Column({ space: 0 }) {
// 1. 头部区域(固定高度,不滚动)
this.buildHeader()
// 2. 内容区(可滚动)
Scroll() {
Column({ space: $r('app.float.spacing_lg') }) {
this.buildBasicInfoCard()
this.buildLanguageCard()
this.buildRegionCard()
this.buildDescriptionCard()
}
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ top: $r('app.float.spacing_md'), bottom: $r('app.float.spacing_xxl') })
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background_color'))
}
}
架构要点:
- 外层 Column:占满整个屏幕
- 状态判断 :
ethnic为空时显示错误视图,有数据时显示正常内容 - 头部固定 :
buildHeader()直接放在 Column 里,不参与滚动 - 内容滚动 :
Scroll包裹所有卡片内容,layoutWeight(1)占满剩余空间 - 卡片间距 :卡片之间用
space: $r('app.float.spacing_lg')统一间距
💡 为什么头部不放在 Scroll 里?
两种设计思路:
- 头部固定:头部一直在顶部,内容在下面滚(「民族图鉴」用的是这种)
- 头部跟随滚动 :头部跟着内容一起滚,滚上去就看不见了(很多资讯类 App 用这种)
没有绝对的好坏,看产品需求。「民族图鉴」选择固定头部,是因为操作按钮(收藏、播放)需要随时可用。
步骤2:Stack 堆叠布局------三层结构的关键
头部区域是三层叠在一起的:背景图在最下面,遮罩层在中间,文字按钮在最上面。
要实现这种"层叠"效果,就要用到 Stack 组件。
2.1 Stack 是什么?
Stack 是堆叠布局------子组件一层一层叠在一起,第一个子组件在最下面,最后一个在最上面。
Stack() {
组件A // 最底层
组件B // 中间层,盖在A上面
组件C // 最顶层,盖在B上面
}
这和 Web 开发里的 position: absolute 堆叠、或者 PS 里的图层是一个意思。
2.2 头部的三层结构
typescript
@Builder
buildHeader(): void {
Stack({ alignContent: Alignment.Start }) {
// 第一层(最底层):背景图片
Image($rawfile(this.ethnic!.coverImage))
.width('100%')
.height(180)
.objectFit(ImageFit.Cover)
// 第二层(中间层):半透明遮罩
Column()
.width('100%')
.height(180)
.backgroundColor(this.ethnic!.emblemColor)
.opacity(0.3)
// 第三层(最顶层):文字和按钮
Column({ space: $r('app.float.spacing_xs') }) {
// ... 操作按钮 + 民族名称 + 人口信息
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ bottom: $r('app.float.spacing_lg') })
}
.width('100%')
}
顺序很重要:
- 先写 Image → 在最下面
- 再写遮罩 Column → 在中间,盖在图片上
- 最后写内容 Column → 在最上面,盖在遮罩上
alignContent: Alignment.Start 表示子组件对齐方式------这里是左上角对齐。
步骤3:背景图片------rawfile 的使用
3.1 为什么用 rawfile?
在 HarmonyOS 里,图片资源有两种存放方式:
| 方式 | 位置 | 引用方式 | 适用场景 |
|---|---|---|---|
| media 资源 | resources/base/media/ |
$r('app.media.xxx') |
应用图标、通用小图 |
| rawfile 资源 | resources/rawfile/ |
$rawfile('xxx.jpg') |
大量图片、动态文件名 |
「民族图鉴」有 56 个民族的封面图,图片数量多,而且文件名是动态的(根据民族数据来的)。这种情况用 rawfile 更合适。
3.2 $rawfile() 的使用方法
typescript
Image($rawfile(this.ethnic!.coverImage))
.width('100%')
.height(180)
.objectFit(ImageFit.Cover)
就这么简单。$rawfile() 里传文件路径(相对于 rawfile 目录)。
比如民族数据里 coverImage 字段的值是 'coverImage/01_han.jpg',那么实际路径就是:
entry/src/main/resources/rawfile/coverImage/01_han.jpg
3.3 objectFit------图片怎么"填"进容器
图片的宽高比和容器的宽高比不一定一样。怎么显示?用 objectFit 属性控制:
| 取值 | 效果 | 适用场景 |
|---|---|---|
Cover |
填满容器,保持比例,超出部分裁剪 | 背景图(推荐) |
Contain |
完整显示,保持比例,可能有留白 | 展示完整图片 |
Fill |
拉伸填满,不保持比例 | 很少用 |
None |
保持原图大小 | 很少用 |
ScaleDown |
缩小显示(如果图比容器大) | 很少用 |
背景图一般用 Cover------保证填满容器,不变形,超出的部分裁掉。虽然会损失一部分图片内容,但整体视觉效果最好。
💡 一个容易踩的坑
如果你用
Image(this.ethnic!.coverImage)直接传字符串路径,图片可能加载不出来。必须用
$rawfile()包裹!这是 ArkUI 的规定------rawfile 资源必须通过$rawfile()引用。同样的,media 资源必须通过
$r('app.media.xxx')引用。两种资源的引用方式不一样,不要搞混了。
步骤4:半透明遮罩层------保证文字可读性
这是详情页顶部的"灵魂"。没有遮罩层,文字可能看不清。
4.1 实现方式
typescript
Column()
.width('100%')
.height(180)
.backgroundColor(this.ethnic!.emblemColor) // 用民族的象征色
.opacity(0.3) // 30% 透明度
就四行代码:
- 一个空的 Column,和头部一样大
- 背景色用民族的象征色(每个民族不一样)
- 透明度设为 0.3(也就是 30% 不透明,70% 透明)
为什么用民族象征色而不是黑色?
- 黑色遮罩:通用、稳妥,但每个民族都一样,没特色
- 主题色遮罩:每个民族颜色不同,更有辨识度
「民族图鉴」选择了主题色遮罩------既保证了文字可读性,又让每个民族的详情页有自己的色调。用户一看顶部颜色,大概就知道是哪个民族了。
4.2 透明度多少合适?
| 透明度 | 效果 | 适用场景 |
|---|---|---|
| 0.1-0.2 | 很淡,图片清晰,但文字可能不太清楚 | 图片本身比较暗 |
| 0.3-0.4 | 适中,图片和文字平衡(推荐) | 大多数情况 |
| 0.5-0.7 | 较深,文字很清楚,但图片不太明显了 | 图片很亮、很杂 |
| 0.8+ | 很深,几乎看不清图片了 | 很少用 |
0.3 是一个比较适中的值。如果你的图片普遍比较亮,可以调到 0.4 甚至 0.5。
4.3 进阶:渐变遮罩
比纯色遮罩更高级的做法是渐变遮罩------上面深、下面浅,或者反过来。
typescript
// 渐变遮罩示意(具体实现看 ArkUI 的渐变 API)
LinearGradient({
// 从透明到半透明黑色
colors: [['#00000000', 0], ['#00000080', 1]]
})
渐变遮罩更自然、更有设计感。但实现起来稍微复杂一点,而且在 ArkUI 里使用渐变需要注意 API 的正确性。
对于初学者来说,纯色遮罩就够用了。等基础打牢了,再尝试渐变也不迟。
步骤5:顶部操作栏------返回、收藏、分享、播放
头部的最上面一行是操作按钮。从左到右:返回、收藏、分享、(右边)播放/暂停。
5.1 整体布局
typescript
Row() {
// 左边:返回按钮
Text('<')
.fontSize(24)
.fontColor('#FFFFFF')
.width(44)
.height(44)
.textAlign(TextAlign.Center)
.onClick(() => {
router.back();
})
// 收藏按钮
Text(this.isFavorite ? '\u2764\u{FE0F}' : '\u2661')
.fontSize(22)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 4 })
.onClick(() => {
this.toggleFavorite();
})
// 分享按钮(仅全量模式显示)
if (!this.isBasicMode) {
Text('\u{1F4E8}')
.fontSize(20)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 4 })
.onClick(() => {
this.shareEthnicInfo();
})
}
// 中间空白,把播放按钮挤到右边
Blank()
// 右边:播放/暂停按钮
Text(this.isPlaying ? '\u{23F8}\u{FE0F}' : '\u{25B6}\u{FE0F}')
.fontSize(22)
.fontColor('#FFFFFF')
.padding(8)
.onClick(() => {
this.toggleTTS();
})
}
.width('100%')
布局思路:
- 左边几个按钮挨在一起(返回 → 收藏 → 分享)
- 中间用
Blank()占满剩余空间 - 右边放播放按钮
- 这样就实现了"左右分散对齐"的效果
5.2 按钮设计细节
返回按钮:
- 用 "<" 符号,简单直接
- 尺寸 44x44vp------这是推荐的最小点击区域
- 白色文字,在深色背景上清晰可见
收藏按钮:
- 空心 ♡ → 实心 ❤️,两种状态一目了然
- 点击切换状态,同时有视觉变化
分享按钮:
- 用 📨 图标(信封)
- 基础模式下隐藏(功能精简)
播放按钮:
- ▶ 播放 / ⏸ 暂停,两种状态切换
- 放在最右边,不干扰其他操作
💡 为什么操作按钮都用白色?
因为背景是深色的(图片 + 遮罩),白色对比度最高,看得最清楚。
这是设计的基本原则------保证对比度 。文字和背景的对比度要足够,用户才能看得清。
WCAG 标准建议正文文本对比度至少 4.5:1,大文本至少 3:1。
步骤6:民族名称与基本信息
操作栏下面,是民族的名称和基本信息。这是头部最重要的内容。
6.1 名称展示
typescript
Text(this.getLocalizedText(this.ethnic!.name, this.ethnic!.nameEn))
.fontSize($r('app.float.font_size_title')) // 大字号
.fontWeight(FontWeight.Bold) // 粗体
.fontColor('#FFFFFF') // 白色
名称是头部的"主角",所以用最大的字号、最粗的字重、最显眼的颜色。
6.2 别名
如果民族有别名,在名称下面显示一行小字:
typescript
if (this.ethnic!.alias && this.ethnic!.alias.length > 0) {
Row({ space: 4 }) {
Text($r('app.string.detail_alias_prefix'))
.fontSize($r('app.float.font_size_sm'))
.fontColor('#FFFFFF')
.opacity(0.85)
Text(this.ethnic!.alias)
.fontSize($r('app.float.font_size_sm'))
.fontColor('#FFFFFF')
.opacity(0.85)
}
}
细节处理:
- 有别名才显示,没有就不显示(
if判断) - 字号小(
font_size_sm,14vp) - 透明度 0.85(比主名称淡一点,不抢戏)
- "别名:" 前缀 + 别名内容,用 Row 横向排列
6.3 人口与排名
再下面是人口信息:
typescript
Row({ space: 4 }) {
Text($r('app.string.detail_population')) // "人口:"
.fontSize($r('app.float.font_size_sm'))
.fontColor('#FFFFFF')
.opacity(0.85)
Text(this.ethnic!.population) // 人口数
.fontSize($r('app.float.font_size_sm'))
.fontWeight(FontWeight.Bold) // 粗体,突出数据
.fontColor('#FFFFFF')
Text(` · #${this.ethnic!.populationRank}`) // 排名
.fontSize($r('app.float.font_size_sm'))
.fontColor('#FFFFFF')
.opacity(0.7) // 最淡
}
信息层级:
- 人口数值(粗体、白色)------ 最显眼
- "人口:" 标签(常规、0.85 透明度)------ 次之
- 排名(0.7 透明度)------ 最淡
虽然都是一行里的文字,但通过字重和透明度的差异,形成了清晰的层级。用户一眼就能抓住重点(人口数),然后才是标签和排名。
💡 设计中的"层级感"
很多初学者做设计,所有文字都是一个颜色、一个字号,看起来平平淡淡,没有重点。
营造层级感的三大法宝:
- 字号:重要的字大一点
- 字重:重要的字粗一点
- 颜色/透明度 :重要的字深一点(或者说,不重要的字淡一点)
灵活运用这三招,你的设计立刻就有"高级感"了。
步骤7:错误状态处理
如果传入的民族 ID 不对,找不到数据怎么办?不能白屏吧?
「民族图鉴」的处理方式是显示一个错误视图:
typescript
if (!this.ethnic) {
Column() {
Text('\u{26A0}\u{FE0F}') // ⚠️ 警告图标
.fontSize(48)
Text($r('app.string.detail_not_found')) // "未找到该民族信息"
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_hint'))
.margin({ top: $r('app.float.spacing_md') })
Text($r('app.string.back')) // "返回" 按钮
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.primary_color'))
.margin({ top: $r('app.float.spacing_sm') })
.onClick(() => {
router.back();
})
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
错误视图三要素:
- 图标:告诉用户"出错了"(⚠️)
- 文字说明:告诉用户"出了什么错"
- 操作按钮:告诉用户"该怎么办"(返回)
用户看到这个页面,不会困惑------他知道是没找到,也知道点"返回"回去。
💡 异常状态设计很重要
很多开发者只考虑"正常流程",对异常情况不闻不问。
但用户会不会输错 ID?会不会传参出问题?会不会网络请求失败?
这些情况如果不处理,用户看到的就是白屏或者闪退,体验非常差。
好的应用,正常流程要做好,异常流程也要处理好。
记住:错误视图也是用户体验的一部分。
🔧 数据加载与状态管理
详情页的数据从哪里来?怎么管理?
数据加载时机
typescript
aboutToAppear(): void {
const params = router.getParams() as Record<string, string>;
if (params?.ethnicId) {
this.ethnic = getEthnicById(params.ethnicId);
if (this.ethnic) {
this.recordViewAndLoadFavorite(this.ethnic.id);
}
}
}
在 aboutToAppear 生命周期里:
- 从路由参数里拿
ethnicId - 用
getEthnicById从 mock 数据里查找民族信息 - 赋值给
this.ethnic,触发 UI 渲染 - 如果找到了,顺便记录浏览历史和加载收藏状态
为什么用 aboutToAppear 而不是 aboutToBuild?
aboutToBuild:每次组件重新构建都会调用,调用频率高aboutToAppear:只在组件出现时调用一次
详情页的数据只需要加载一次,所以放在 aboutToAppear 里更合适。
异步操作的处理
记录浏览历史和加载收藏状态是异步操作(需要读写本地存储),但 aboutToAppear 不支持 async/await。
怎么处理?用 fire-and-forget 模式:
typescript
private async recordViewAndLoadFavorite(ethnicId: string): Promise<void> {
try {
const storage = StorageService.getInstance();
await storage.saveViewedEthnic(ethnicId);
this.isFavorite = await storage.isFavoriteEthnic(ethnicId);
} catch (e) {
console.error('[DetailPage] record view failed:', JSON.stringify(e));
}
}
调用的时候不加 await:
typescript
if (this.ethnic) {
this.recordViewAndLoadFavorite(this.ethnic.id); // 异步调用,不等待结果
}
这样:
- 页面不会因为等待存储操作而卡顿
- 存储操作完成后,通过给
@State变量赋值来触发 UI 更新 - 出错了打个日志就行,不影响主流程
这是移动端开发常用的技巧------UI 优先,数据异步更新。
🐛 常见问题与解决方案
问题1:图片加载不出来
现象:顶部背景图是空白的,什么都不显示。
可能的原因和解决方案:
原因1:路径写错了
检查文件路径是否正确。$rawfile() 的路径是相对于 rawfile 目录的。
比如你的图片在 rawfile/coverImage/01_han.jpg,那路径应该是 'coverImage/01_han.jpg',而不是 '01_han.jpg'。
原因2:资源没有打包进去
检查图片文件是不是真的在 rawfile 目录下。有时候文件放错位置了,或者文件名大小写不对(Linux 环境区分大小写)。
原因3:引用方式错了
typescript
// ❌ 错误:直接传字符串
Image(this.ethnic!.coverImage)
// ✅ 正确:用 $rawfile() 包裹
Image($rawfile(this.ethnic!.coverImage))
rawfile 资源必须用 $rawfile() 引用,这是 ArkUI 的规定。
问题2:文字在图片上看不清
现象:白色文字放在浅色的图片区域,几乎看不见。
解决方案:
方案1:加深遮罩层(最简单)
把遮罩层的透明度从 0.3 调到 0.4 或 0.5。
typescript
.opacity(0.5) // 从 0.3 调到 0.5
立竿见影,但图片会变得更暗。
方案2:用黑色遮罩代替彩色遮罩
黑色遮罩对文字对比度的提升效果最好。
typescript
.backgroundColor('#000000') // 黑色
.opacity(0.4)
方案3:给文字加阴影
给文字加个细微的文字阴影,让文字在任何背景上都能看清。
typescript
Text(name)
.fontColor('#FFFFFF')
.textShadow({
radius: 4,
color: '#00000080',
offsetX: 0,
offsetY: 1
})
文字阴影比较耗性能,不要滥用。但在标题文字上用一用,效果还是不错的。
方案4:渐变遮罩(推荐)
文字集中在底部,就让底部的遮罩深一些,顶部浅一些。这样图片顶部看得清楚,底部文字也读得清楚。
typescript
// 渐变遮罩示意
// 从上到下:透明 → 半透明黑色
LinearGradient({
colors: [['#00000000', 0], ['#00000080', 1]],
direction: GradientDirection.Bottom
})
渐变遮罩是最"高级"的做法,视觉效果最好。但实现起来也稍微复杂一点。
问题3:顶部区域太矮/太高
现象:觉得头部 180vp 太矮了,想弄高一点,展示更多图片内容。
调整方法:
直接改高度值就行。但有几个注意点:
- 不要太高:头部太高,内容就被挤下去了,用户一屏看不到正文。建议 150-250vp。
- 图片质量要够:头部越高,需要的图片分辨率越高。如果图片太小,放大了会模糊。
- 内容要居中 :头部高度变了,里面的文字位置也要调整。用
Blank()或者justifyContent来控制垂直位置。
「民族图鉴」选 180vp,是一个平衡点------既能展示足够的图片内容,又不会挤占正文空间。
问题4:返回按钮点击区域太小
现象:用户想点返回,总是点不中。
解决方案:
加大点击区域!
typescript
// ✅ 好:宽高 44vp,点击区域足够大
Text('<')
.width(44)
.height(44)
.textAlign(TextAlign.Center)
.onClick(() => { router.back(); })
// ❌ 不好:只有文字大小,很难点中
Text('<')
.onClick(() => { router.back(); })
移动端的最小点击区域建议是 44x44vp。这是 Apple 和 Google 的设计规范里都推荐的值。
别小看这几 vp 的差距------用户用大拇指操作手机的时候,差几 vp 可能就是"一点就中"和"点半天点不中"的区别。
💡 触控目标设计原则
- 最小 44x44vp(必要时 40x40vp 也能接受)
- 按钮之间留足够间距,防止误触
- 重要按钮(返回、确认、提交)放容易点到的位置
这些都是被无数次验证过的经验,照着做就对了。
问题5:Stack 里的内容位置不对
现象:Stack 里的文字没有按预期的位置显示,要么偏上要么偏下。
解决方案:
Stack 的对齐方式由 alignContent 属性控制:
typescript
Stack({ alignContent: Alignment.TopStart }) {
// 子组件
}
常用的对齐方式:
| 对齐方式 | 效果 |
|---|---|
TopStart |
左上角(默认) |
TopCenter |
顶部居中 |
TopEnd |
右上角 |
Center |
正中间 |
BottomStart |
左下角 |
BottomCenter |
底部居中 |
BottomEnd |
右下角 |
如果所有子组件统一对齐,用 alignContent 就够了。
如果某个子组件需要单独对齐呢?在子组件外面包一层容器,用 justifyContent / alignItems 控制,或者用 position 绝对定位。
「民族图鉴」的做法是:Stack 用 Alignment.Start(左上角对齐),然后在内容 Column 里用 Blank() 来撑开空间,把文字推到底部。
typescript
Column({ space: $r('app.float.spacing_xs') }) {
// 顶部:操作按钮行
Row() { /* ... */ }
.width('100%')
// 中间空白(把内容往下推)
Blank()
// 底部:民族名称
Text(name)
// 再下面:别名、人口
// ...
Blank() // 底部再留一点空白
}
用 Blank() 比写死 padding 更灵活------不管头部高度怎么变,内容总是能自适应。
🧠 进阶拓展:详情页头部的深度设计
6.1 详情页头部的三种设计模式
详情页的头部不只是"一张图加一个标题"这么简单。根据内容类型和产品定位的不同,头部有三种典型的设计模式。
6.1.1 大图模式(Big Image)
特点:顶部是一张大图,标题浮在图片上,视觉冲击力强。
┌─────────────────────────┐
│ │
│ 背景大图 │
│ │
│ ┌─────────────────┐ │
│ │ 标题文字 │ │
│ │ 副标题 │ │
│ └─────────────────┘ │
└─────────────────────────┘
优点:
- 视觉冲击力强,第一印象好
- 适合图片质量高的内容
- 用户沉浸感强
缺点:
- 图片不好的话效果大打折扣
- 文字在图片上,可读性受影响
- 头部占空间大,正文被挤得靠下
适用场景:
- 图片质量高的内容(风景、人物、美食)
- 内容型、展示型产品
- 小红书、抖音、马蜂窝
「民族图鉴」用的就是这种模式------每个民族有代表性的服饰/风景图片,视觉效果好。
6.1.2 渐变背景模式(Gradient)
特点:顶部不是图片,而是渐变色背景,标题在渐变色上。
┌─────────────────────────┐
│ <-- 返回 分享 │
│ │
│ │
│ 民族名称 │
│ 别名 / 人口 │
│ │
└─────────────────────────┘
渐变色背景(从深到浅)
优点:
- 不需要图片,实现简单
- 文字可读性好
- 可以用主题色,品牌感强
- 加载快,没有图片加载的问题
缺点:
- 视觉冲击力不如图片
- 比较单调,不够生动
适用场景:
- 没有合适图片的内容
- 工具型、功能性产品
- 列表类、数据类详情页
6.1.3 透明栏模式(Transparent Bar)
特点:导航栏是透明的,内容可以延伸到状态栏下面,全屏显示。
┌─────────────────────────┐
│ <-- 返回 分享 │ ← 透明导航栏,文字是白色
│ │
│ 背景大图 │
│ │
│ 标题文字 │
├─────────────────────────┤
│ │
│ 正文内容 │
优点:
- 沉浸感最强,视觉效果最好
- 内容区域更大,一屏能看到更多
- 现代感强,显得高级
缺点:
- 实现复杂,要处理滚动时的导航栏变色
- 状态栏文字颜色要适配(深色/浅色)
- 兼容性要求高
适用场景:
- 图片质量高、视觉导向的产品
- 高端、精致的产品定位
- 小红书、知乎、豆瓣
💡 三种模式怎么选?
- 有好看的图,想做视觉冲击 → 大图模式
- 没有图,或者功能性强 → 渐变背景模式
- 追求极致体验,有开发资源 → 透明栏模式
「民族图鉴」现阶段用大图模式就够了。以后想升级体验,可以改成透明栏模式。
6.2 滚动时的头部动画
如果只是一张大图放在那,也太无聊了。好的详情页,头部会跟着滚动有动画变化------往下滚的时候图片慢慢折叠、导航栏慢慢浮现。
6.2.1 三种常见的滚动动效
1. 视差滚动(Parallax)
手指往下滑,背景图移动的速度比内容慢,营造出层次感和空间感。
手指往下滑 200vp:
- 内容往下移动 200vp
- 背景图往下移动 100vp(速度是内容的一半)
效果:背景图好像在"更远处",有纵深感。
2. 头部折叠(Collapsing)
往下滚的时候,头部高度慢慢变小,最后变成导航栏的高度。
初始状态:头部高 250vp,大标题
往下滚:头部逐渐缩小,标题逐渐变小
滚到顶:头部变成 56vp 的导航栏,小标题
效果:一开始展示完整内容,滚动后收起,给内容腾出空间。
3. 导航栏渐变出现
一开始导航栏是透明的,往下滚的时候慢慢从透明变成纯色。
初始状态:导航栏完全透明
往下滚:导航栏越来越不透明
滚到一定位置:导航栏完全变成纯色
效果:沉浸感和实用性兼顾------顶部看图片的时候沉浸,往下看内容的时候导航栏可见。
6.2.2 「民族图鉴」的滚动动效实现思路
我们可以做一个"导航栏渐变出现"的效果:
typescript
// 1. 记录滚动偏移量
@State scrollY: number = 0;
// 2. 计算导航栏背景透明度
// 滚动 100vp 内,透明度从 0 变到 1
get navBarOpacity(): number {
if (this.scrollY <= 0) return 0;
if (this.scrollY >= 100) return 1;
return this.scrollY / 100;
}
// 3. 监听滚动
Scroll(this.scroller) {
// ... 内容
}
.onScroll((scrollOffset: number, scrollState: ScrollState) => {
this.scrollY = scrollOffset;
})
// 4. 导航栏背景动态变化
Stack({ alignContent: Alignment.Top }) {
// 内容...
// 顶部导航栏
Row() {
// 返回按钮、标题、操作按钮
}
.width('100%')
.height(56)
.backgroundColor(`rgba(255, 255, 255, ${this.navBarOpacity})`)
}
这样滚动的时候,导航栏会从透明慢慢变成白色,标题文字也会从白色慢慢变成深色。既有沉浸感,又不影响使用。
💡 动效设计的原则:
- 跟手:动效要跟着手指走,不是独立的
- 自然:动画曲线要自然,不要生硬
- 有度:动效是辅助,不要喧宾夺主
- 性能:动效不能卡,要流畅
好的动效,用户用的时候觉得"舒服",但又说不出哪里好。这就是设计的最高境界。
6.3 操作按钮的交互设计
详情页头部有几个重要的操作按钮:返回、分享、收藏。这些按钮虽小,但交互设计很有讲究。
6.3.1 返回按钮
返回按钮是用户用得最多的按钮------几乎每个用户都会点返回。
设计要点:
- 位置:左上角,用户一眼就能找到
- 大小:至少 44×44vp,好点
- 样式:不要太花哨,简洁明了
- 反馈:点击有反馈(透明度变化)
返回的几种方式:
- 点击返回按钮
- 手势返回(左滑返回)
- 物理返回键(Android)
鸿蒙系统支持手势返回,所以我们只要做好返回按钮就行,其他的系统会处理。
6.3.2 分享按钮
分享按钮的重要性不言而喻------分享是产品传播的重要途径。
设计要点:
- 位置:右上角,容易找到
- 图标:公认的分享图标(三个点连起来,或者箭头向外)
- 点击反馈:按下有效果
分享面板的设计:
点击分享按钮后,一般会弹出一个分享面板,列出可以分享的渠道:
- 微信好友
- 微信朋友圈
- 微博
- 复制链接
- 保存图片
「民族图鉴」的分享可以做两种形式:
- 文字分享:分享民族介绍的文字
- 海报分享:生成一张精美的海报图片,用户可以保存和分享
海报分享的传播效果更好------图片比文字更吸引眼球。
6.3.3 收藏按钮
收藏按钮是提升用户留存的重要功能------用户收藏的内容越多,越舍不得卸载。
设计要点:
- 两种状态:未收藏(空心星星)、已收藏(实心星星)
- 颜色变化:未收藏灰色,已收藏金色/主题色
- 点击动画:点击收藏时有个放大缩小的动画,有"点赞"的感觉
收藏动画的实现:
typescript
@State isFavorite: boolean = false;
@State favoriteScale: number = 1;
// 收藏按钮
Image(this.isFavorite ? $r('app.media.ic_star_filled') : $r('app.media.ic_star'))
.width(24)
.height(24)
.fillColor(this.isFavorite ? '#FFD700' : '#FFFFFF')
.scale({ x: this.favoriteScale, y: this.favoriteScale })
.onClick(() => {
// 点击动画:放大 → 缩小 → 恢复
animateTo({ duration: 100, curve: Curve.EaseOut }, () => {
this.favoriteScale = 1.3;
});
setTimeout(() => {
animateTo({ duration: 150, curve: Curve.EaseIn }, () => {
this.favoriteScale = 1;
});
}, 100);
// 切换收藏状态
this.isFavorite = !this.isFavorite;
})
点击的时候,星星先放大再缩小,像"跳一下"的感觉,很有满足感。
💡 按钮设计的小细节:
- 图标和文字至少要有一个------只放图标要保证用户能看懂
- 重要按钮放容易点到的位置
- 点击反馈要即时,不要让用户等
- 状态变化要明显(比如收藏前后颜色不一样)
按钮虽小,但用户每次用都能感受到设计的用心。
6.4 民族主题色的提取与应用
「民族图鉴」有56个民族,每个民族都有自己的特色。如果每个民族的详情页都有自己的主题色,会不会更有特色?
6.4.1 什么是主题色提取?
从图片中提取出最主要的颜色,作为页面的主题色。这样每个民族的详情页都有独特的色彩风格。
傣族详情页 → 从傣族服饰图片提取 → 孔雀蓝主题色
藏族详情页 → 从藏族服饰图片提取 → 藏红/金黄主题色
蒙古族详情页 → 从蒙古族服饰图片提取 → 草原蓝主题色
6.4.2 主题色可以用在哪里?
提取出主题色后,可以用在这些地方:
- 导航栏背景色
- 标题文字颜色
- 按钮颜色
- 标签颜色
- 分隔线颜色
- 进度条颜色
整个页面的色彩都和图片呼应,视觉上更和谐、更有沉浸感。
6.4.3 实现思路
鸿蒙系统目前还没有内置的图片取色 API,但我们可以用几种方式实现:
方案1:提前配置(最简单)
在民族数据里预先配置好每个民族的主题色:
typescript
interface EthnicInfo {
id: string;
name: string;
// ...
themeColor: string; // 主题色
}
const ethnicList: EthnicInfo[] = [
{
id: '01',
name: '傣族',
themeColor: '#00A896', // 孔雀绿
// ...
},
{
id: '02',
name: '藏族',
themeColor: '#C41E3A', // 藏红
// ...
},
// ...
];
优点:
- 实现最简单
- 颜色准确可控
- 性能最好
缺点:
- 需要人工配置
- 图片换了颜色不会自动变
对于56个民族来说,人工配置完全可行,而且效果最好。所以「民族图鉴」推荐用这种方式。
方案2:动态提取(进阶)
如果图片是动态的(比如用户上传的),就需要动态提取。可以用一些第三方库或者自己实现颜色提取算法(比如中位切分法、K-means聚类)。
但这比较复杂,性能也一般。除非真的需要,否则不建议。
💡 色彩设计的注意事项:
- 文字对比度:主题色上的文字,对比度一定要够(4.5:1以上)
- 不要太杂:一个页面不要有太多颜色,主色+辅色就够了
- 保持统一:不同页面的主题色风格要统一,不要有的艳丽有的素雅
- 考虑色弱:不要用色弱用户难以区分的颜色组合
颜色是把双刃剑------用得好锦上添花,用不好画蛇添足。
6.5 沉浸式状态栏与图片延伸
想要更沉浸的体验?那就让图片延伸到状态栏下面,做成全屏的效果。
6.5.1 什么是沉浸式状态栏?
普通的页面,状态栏(显示时间、电量的那一条)是独立的,有自己的背景色。
沉浸式状态栏,就是让页面内容延伸到状态栏下面,状态栏变成透明的,内容可以从屏幕最顶部开始显示。
普通模式:
┌─────────────────────────┐
│ 状态栏(黑色/白色背景) │
├─────────────────────────┤
│ │
│ 页面内容 │
│ │
沉浸式模式:
┌─────────────────────────┐
│ 状态栏(透明,浮在上面) │
│ ───────────────────── │
│ 页面内容(从顶开始) │
│ │
6.5.2 实现沉浸式状态栏
在鸿蒙里,可以通过设置窗口属性来实现沉浸式状态栏:
typescript
// 在页面的 aboutToAppear 里设置
aboutToAppear(): void {
// 获取窗口实例
const windowClass = window.getWindow(getContext());
// 设置状态栏为透明
windowClass.setWindowLayoutFullScreen(true);
// 设置状态栏文字颜色(深色/浅色)
windowClass.setWindowSystemBarProperties({
statusBarContentColor: '#FFFFFF', // 白色文字
statusBarColor: '#00000000' // 透明背景
});
}
但要注意:
- 内容延伸到状态栏下面了,那顶部的内容(比如返回按钮)要往下挪,避开状态栏的高度
- 状态栏文字颜色要和背景搭配------背景浅用深色文字,背景深用浅色文字
- 离开页面的时候要恢复原状,不然其他页面也变成沉浸式了
6.5.3 「民族图鉴」的适配
我们的详情页头部有一张大图,很适合做沉浸式效果:
┌─────────────────────────┐
│ <-- 返回 分享 │ ← 按钮在状态栏下方,避开状态栏
│ │
│ 背景大图 │
│ │
│ 民族名称 │
│ 别名 / 人口 │
实现步骤:
- 开启全屏布局
- 顶部按钮往下挪(状态栏高度 + 一点边距)
- 滚动到一定位置后,状态栏文字颜色从白变黑
这样既有沉浸感,又不影响使用。
💡 沉浸式的注意事项:
- 适配不同机型:不同手机的状态栏高度不一样,要动态获取
- 避开安全区域:刘海屏、挖孔屏要避开摄像头的位置
- 可访问性:不要让重要内容被状态栏挡住
- 性能:沉浸式本身不影响性能,但如果配合复杂的滚动动画,就要注意了
沉浸式虽然好看,但也要适度------不是所有页面都适合沉浸式。列表页、设置页这种功能性页面,就没必要沉浸式,老老实实的更清楚。
6.6 头部的多种状态
详情页头部不只有"正常显示"这一种状态。至少要考虑三种状态:加载中、加载失败、无图。
6.6.1 加载中状态
图片还在加载的时候,头部显示什么?
方案1:纯色占位
用一个纯色的矩形占位,图片加载完替换。
typescript
Stack() {
// 占位:纯色背景
Rect()
.width('100%')
.height('100%')
.fill('#E0E0E0')
// 图片
Image(coverImage)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.imageLoaded ? 1 : 0)
.animation({ duration: 300 })
.onComplete(() => {
this.imageLoaded = true;
})
}
方案2:骨架屏
用灰色的占位框模拟头部布局,让用户知道"这里有内容,马上出来"。
骨架屏比纯色占位体验更好------用户能大致知道内容长什么样,等待的焦虑感会少一些。
6.6.2 加载失败状态
图片加载失败了(网络不好、图片不存在),怎么办?
方案1:默认图
显示一张默认的占位图,比如一个统一的民族图案。
typescript
Image(this.imageLoadFailed ? $r('app.media.default_cover') : coverImage)
.onError(() => {
this.imageLoadFailed = true;
})
方案2:渐变背景
不用图片了,直接用渐变色背景代替,文字放在渐变色上。
typescript
if (this.imageLoadFailed) {
// 渐变背景
Rect()
.width('100%')
.height('100%')
.fillLinearGradient(...)
}
「民族图鉴」推荐两种都做------先尝试加载图片,失败了就用该民族的主题色渐变背景。这样就算图片加载失败,页面也不会丑。
6.6.3 无图状态
有的民族可能没有合适的图片怎么办?
- 用主题色渐变背景代替
- 用民族名称的首字作为图标
- 用统一的民族文化图案
没有图片不可怕,可怕的是因为没有图片就做得很丑。用好颜色和排版,就算没有图片也能很好看。
💡 状态设计的重要性 :
很多开发者只关心"正常状态",忽略了异常状态。但用户对产品的印象,往往是在异常状态下形成的。
加载快不快?加载的时候有没有骨架屏?出错了能不能重试?没数据的时候有没有引导?
这些细节做好了,用户会觉得"这个 App 很靠谱"。
好的产品,就是在别人注意不到的地方也下了功夫。
📝 本章小结
核心知识点
本文深入讲解了民族详情页的顶部设计,从架构到细节:
1. 详情页整体架构
- 固定头部 + 滚动内容区的经典结构
- 头部放操作按钮和标题,内容区放详细信息
- 状态判断:数据加载失败显示错误视图
2. Stack 堆叠布局
- 子组件按顺序堆叠,先写的在下面,后写的在上面
- 三层结构:背景图 → 遮罩层 → 内容层
alignContent控制整体对齐方式
3. rawfile 图片引用
- 大量动态图片用 rawfile,静态小图用 media
$rawfile('path/to/image.jpg')引用objectFit: ImageFit.Cover填满容器
4. 半透明遮罩层
- 作用:保证图片上的文字可读性
- 实现:空 Column + 背景色 + opacity
- 「民族图鉴」用民族主题色 + 0.3 透明度
5. 顶部操作栏
- 布局:左对齐操作按钮 + Blank + 右对齐播放按钮
- 按钮最小点击区域 44x44vp
- 白色文字保证在深色背景上的对比度
6. 信息层级设计
- 三级标题:名称(最大最粗)→ 别名(中)→ 排名(最淡)
- 三大法宝:字号、字重、颜色/透明度
- 有了层级,用户才能快速抓住重点
7. 错误状态处理
- 三要素:图标 + 文字 + 操作按钮
- 异常状态也是用户体验的一部分
- 不能让用户看到白屏或闪退
最佳实践总结
✅ 详情页经典结构:固定头部 + 滚动内容
typescript
Column() {
Header() // 固定,不滚动
Scroll() { // 可滚动
Content()
}
.layoutWeight(1)
}
✅ 大图头部三层结构:背景 + 遮罩 + 内容
typescript
Stack() {
Image() // 底层:背景图
Column() // 中层:半透明遮罩
.backgroundColor(color)
.opacity(0.3)
Column() { // 顶层:文字和按钮
// 内容
}
}
✅ rawfile 图片要用 $rawfile() 引用
typescript
// ✅ 正确
Image($rawfile('cover/image.jpg'))
// ❌ 错误
Image('cover/image.jpg')
✅ 按钮最小点击区域 44x44vp
typescript
Text('<')
.width(44)
.height(44)
.textAlign(TextAlign.Center)
✅ 信息层级三要素:字号、字重、颜色
主标题:大、粗、深 ✅
副标题:中、常规、中 ✅
辅助信息:小、细、浅 ✅
✅ 错误视图三要素:图标 + 说明 + 操作
⚠️
未找到该民族信息
[返回]
下一篇预告
顶部头部做好了,接下来就是下面的内容区了。详情页内容区有好几张卡片,其中最基础也最重要的,就是基本信息卡片。
下一篇(第20篇)我们将讲解民族详情页------基本信息卡片与网格布局:
- 卡片组件的通用设计模式
- Grid 网格布局的详细使用
- 信息项组件的封装(图标 + 标签 + 数值)
- 2x2 网格布局的实现细节
- 卡片的内边距、圆角、阴影规范
- 如何把重复代码抽成可复用的 @Builder 方法
从下一篇开始,我们会接触到"组件封装"的思想------把重复的代码抽出来,复用再复用。这是从"会写代码"到"写好代码"的重要一步。
🔗 相关链接
- 项目源码 : GitCode 仓库
- Stack 组件 : 官方文档
- Image 组件 : 官方文档
- $rawfile 资源 : 官方文档
- 资源管理 : 官方文档
💡 提示:详情页是应用里"最值钱"的页面------用户在这里停留时间最长,对应用质量的感知也最深。很多开发者把精力都放在列表页上,觉得列表页做好了就行,详情页随便糊弄一下------大错特错。列表页是"引流"的,详情页才是"转化"的。用户因为列表页点进来,但因为详情页决定要不要留下来、要不要推荐给别人。所以,详情页值得花更多时间去打磨。顶部大图只是第一步,下面的每一张卡片、每一段文字、每一个交互细节,都要认真对待。做详情页,要有"做作品"的心态。