HarmonyOS应用《民族图鉴》开发第19篇:民族详情页——顶部背景与信息架构深度解析

📖 引言

如果说列表页是应用的"货架",那详情页就是应用的"商品详情"------用户真正花时间阅读、消费内容的地方。

56 个民族,每个民族都有自己的故事。怎么把这些故事讲好?怎么让用户愿意停下来、读下去?这就是详情页要解决的问题。

「民族图鉴」的详情页,从上到下是这样的:

  • 顶部头部区域:大图背景 + 民族名称 + 操作按钮(返回/收藏/分享/播放)
  • 基本信息卡片:宗教、语系、文字、人口排行,2x2 网格
  • 语言文字卡片:使用语言、所属语系、文字系统
  • 分布地区卡片:主要分布区 + 省份标签
  • 详细介绍卡片:长文本介绍 + TTS 语音朗读

信息很多,但层次分明。用户一眼扫过去,就知道"哪里有什么"。

这一篇,我们先聚焦在顶部头部区域。别看它只有 180vp 高,里面的学问可大了:背景图怎么加、遮罩层怎么做、文字在图片上怎么保证可读性、操作按钮怎么布局、Stack 堆叠布局怎么用......

把顶部做好了,详情页就成功了一半。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握详情页的整体信息架构设计方法
  • ✅ 学会使用 Stack 堆叠布局实现层叠效果
  • ✅ 掌握 rawfile 图片的引用方式($rawfile())
  • ✅ 理解半透明遮罩层的作用与实现
  • ✅ 学会在图片背景上保证文字可读性
  • ✅ 掌握顶部操作栏的布局设计(返回/收藏/分享/播放)
  • ✅ 理解错误状态的处理方式
  • ✅ 能够设计出视觉效果好、信息清晰的详情页头部

💡 需求分析

详情页的核心需求

需求点 说明 为什么重要
信息展示 完整展示民族的所有信息 详情页的核心价值
视觉效果 顶部大图,有冲击力 第一印象,吸引用户
操作功能 返回、收藏、分享、语音朗读 基础交互功能
信息层级 主次分明,重点突出 让用户快速找到想看的
阅读体验 长文本排版舒适 用户愿意读下去
加载状态 数据加载中/失败有提示 不让用户困惑

顶部头部区域的需求拆解

顶部区域虽然不大,但"麻雀虽小,五脏俱全":

复制代码
┌─────────────────────────────────────┐
│ ←   ♡   📨                  ▶/⏸   │  ← 操作栏:返回、收藏、分享、播放
│                                     │
│                                     │
│        汉族                         │  ← 民族名称(大字、粗体)
│        别名:华夏族、汉人            │  ← 别名(小字、浅色)
│        人口:约12亿人 · #1           │  ← 人口 + 排名
│                                     │
└─────────────────────────────────────┘
          ↑ 背景图 + 半透明遮罩

三层结构

  1. 底层:背景图片(铺满整个头部)
  2. 中层:半透明遮罩(保证文字可读)
  3. 顶层:文字和按钮(用户实际看到的内容)

🎨 设计理念:为什么用大图背景?

在讲代码之前,先聊聊设计。为什么很多 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'))
  }
}

架构要点

  1. 外层 Column:占满整个屏幕
  2. 状态判断ethnic 为空时显示错误视图,有数据时显示正常内容
  3. 头部固定buildHeader() 直接放在 Column 里,不参与滚动
  4. 内容滚动Scroll 包裹所有卡片内容,layoutWeight(1) 占满剩余空间
  5. 卡片间距 :卡片之间用 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%')
}

顺序很重要

  1. 先写 Image → 在最下面
  2. 再写遮罩 Column → 在中间,盖在图片上
  3. 最后写内容 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% 透明度

就四行代码:

  1. 一个空的 Column,和头部一样大
  2. 背景色用民族的象征色(每个民族不一样)
  3. 透明度设为 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)                                // 最淡
}

信息层级

  1. 人口数值(粗体、白色)------ 最显眼
  2. "人口:" 标签(常规、0.85 透明度)------ 次之
  3. 排名(0.7 透明度)------ 最淡

虽然都是一行里的文字,但通过字重和透明度的差异,形成了清晰的层级。用户一眼就能抓住重点(人口数),然后才是标签和排名。

💡 设计中的"层级感"

很多初学者做设计,所有文字都是一个颜色、一个字号,看起来平平淡淡,没有重点。

营造层级感的三大法宝:

  1. 字号:重要的字大一点
  2. 字重:重要的字粗一点
  3. 颜色/透明度 :重要的字深一点(或者说,不重要的字淡一点)
    灵活运用这三招,你的设计立刻就有"高级感"了。

步骤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)
}

错误视图三要素

  1. 图标:告诉用户"出错了"(⚠️)
  2. 文字说明:告诉用户"出了什么错"
  3. 操作按钮:告诉用户"该怎么办"(返回)

用户看到这个页面,不会困惑------他知道是没找到,也知道点"返回"回去。

💡 异常状态设计很重要

很多开发者只考虑"正常流程",对异常情况不闻不问。

但用户会不会输错 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 生命周期里:

  1. 从路由参数里拿 ethnicId
  2. getEthnicById 从 mock 数据里查找民族信息
  3. 赋值给 this.ethnic,触发 UI 渲染
  4. 如果找到了,顺便记录浏览历史和加载收藏状态

为什么用 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 太矮了,想弄高一点,展示更多图片内容。

调整方法

直接改高度值就行。但有几个注意点:

  1. 不要太高:头部太高,内容就被挤下去了,用户一屏看不到正文。建议 150-250vp。
  2. 图片质量要够:头部越高,需要的图片分辨率越高。如果图片太小,放大了会模糊。
  3. 内容要居中 :头部高度变了,里面的文字位置也要调整。用 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})`)
}

这样滚动的时候,导航栏会从透明慢慢变成白色,标题文字也会从白色慢慢变成深色。既有沉浸感,又不影响使用。

💡 动效设计的原则

  1. 跟手:动效要跟着手指走,不是独立的
  2. 自然:动画曲线要自然,不要生硬
  3. 有度:动效是辅助,不要喧宾夺主
  4. 性能:动效不能卡,要流畅

好的动效,用户用的时候觉得"舒服",但又说不出哪里好。这就是设计的最高境界。


6.3 操作按钮的交互设计

详情页头部有几个重要的操作按钮:返回、分享、收藏。这些按钮虽小,但交互设计很有讲究。

6.3.1 返回按钮

返回按钮是用户用得最多的按钮------几乎每个用户都会点返回。

设计要点

  • 位置:左上角,用户一眼就能找到
  • 大小:至少 44×44vp,好点
  • 样式:不要太花哨,简洁明了
  • 反馈:点击有反馈(透明度变化)

返回的几种方式

  1. 点击返回按钮
  2. 手势返回(左滑返回)
  3. 物理返回键(Android)

鸿蒙系统支持手势返回,所以我们只要做好返回按钮就行,其他的系统会处理。

6.3.2 分享按钮

分享按钮的重要性不言而喻------分享是产品传播的重要途径。

设计要点

  • 位置:右上角,容易找到
  • 图标:公认的分享图标(三个点连起来,或者箭头向外)
  • 点击反馈:按下有效果

分享面板的设计

点击分享按钮后,一般会弹出一个分享面板,列出可以分享的渠道:

  • 微信好友
  • 微信朋友圈
  • QQ
  • 微博
  • 复制链接
  • 保存图片

「民族图鉴」的分享可以做两种形式:

  1. 文字分享:分享民族介绍的文字
  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聚类)。

但这比较复杂,性能也一般。除非真的需要,否则不建议。

💡 色彩设计的注意事项

  1. 文字对比度:主题色上的文字,对比度一定要够(4.5:1以上)
  2. 不要太杂:一个页面不要有太多颜色,主色+辅色就够了
  3. 保持统一:不同页面的主题色风格要统一,不要有的艳丽有的素雅
  4. 考虑色弱:不要用色弱用户难以区分的颜色组合

颜色是把双刃剑------用得好锦上添花,用不好画蛇添足。


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 「民族图鉴」的适配

我们的详情页头部有一张大图,很适合做沉浸式效果:

复制代码
┌─────────────────────────┐
│  <-- 返回          分享 │  ← 按钮在状态栏下方,避开状态栏
│                         │
│      背景大图           │
│                         │
│       民族名称          │
│       别名 / 人口       │

实现步骤:

  1. 开启全屏布局
  2. 顶部按钮往下挪(状态栏高度 + 一点边距)
  3. 滚动到一定位置后,状态栏文字颜色从白变黑

这样既有沉浸感,又不影响使用。

💡 沉浸式的注意事项

  1. 适配不同机型:不同手机的状态栏高度不一样,要动态获取
  2. 避开安全区域:刘海屏、挖孔屏要避开摄像头的位置
  3. 可访问性:不要让重要内容被状态栏挡住
  4. 性能:沉浸式本身不影响性能,但如果配合复杂的滚动动画,就要注意了

沉浸式虽然好看,但也要适度------不是所有页面都适合沉浸式。列表页、设置页这种功能性页面,就没必要沉浸式,老老实实的更清楚。


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 方法

从下一篇开始,我们会接触到"组件封装"的思想------把重复的代码抽出来,复用再复用。这是从"会写代码"到"写好代码"的重要一步。


🔗 相关链接


💡 提示:详情页是应用里"最值钱"的页面------用户在这里停留时间最长,对应用质量的感知也最深。很多开发者把精力都放在列表页上,觉得列表页做好了就行,详情页随便糊弄一下------大错特错。列表页是"引流"的,详情页才是"转化"的。用户因为列表页点进来,但因为详情页决定要不要留下来、要不要推荐给别人。所以,详情页值得花更多时间去打磨。顶部大图只是第一步,下面的每一张卡片、每一段文字、每一个交互细节,都要认真对待。做详情页,要有"做作品"的心态。