HarmonyOS应用《民族图鉴》开发第18篇:民族列表项——卡片设计与信息展示深度解析

📖 引言

如果说列表页是应用的"货架",那每一张卡片就是货架上的"商品包装"。

包装好不好看,直接决定了用户愿不愿意拿起来看看。56 个民族,每个民族都是一张卡片------同样的结构,不同的颜色、不同的名字、不同的数据。怎么把这些卡片做得既统一规范、又各有特色,让用户愿意一个个点进去看?

这就是本篇要讲的内容。

「民族图鉴」的列表项有两种形态:

  • 网格卡片:4 列布局,信息密度高,适合"逛"
  • 列表项:单列布局,信息完整,适合"找"

两种形态虽然外观不同,但底层设计理念是一致的:清晰的信息层级、明确的视觉引导、舒适的交互反馈

本篇我们就从设计原则讲起,深入到每一行代码的实现细节,再到交互反馈、无障碍适配、性能优化。卡片是列表的细胞------每个细胞都健康,整个列表才有活力。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握卡片设计的基本原则与信息层级设计
  • ✅ 学会实现网格卡片(圆形图标 + 名称 + 辅助信息)
  • ✅ 学会实现列表项(左图标 + 中间信息 + 右箭头)
  • ✅ 理解点击反馈的实现原理(onTouch + 状态变量 + 属性动画)
  • ✅ 掌握无障碍适配的基本方法(accessibilityText)
  • ✅ 了解卡片性能优化的关键点
  • ✅ 能够设计出美观、好用、高性能的列表项

💡 需求分析

列表项的核心需求

需求点 说明 为什么重要
信息展示 名称、图标、辅助信息(人口/地区) 用户快速识别内容
点击跳转 点击进入详情页 内容消费的入口
点击反馈 按下有视觉反馈 让用户知道"点到了"
一致性 所有卡片风格统一 视觉规范,不杂乱
高性能 快速渲染、滚动流畅 基础体验,不能卡
无障碍 屏幕阅读器可识别 适配所有用户

两种卡片形态的对比

维度 网格卡片 列表项
布局方向 垂直(图标在上,文字在下) 水平(图标在左,文字在中)
信息密度 高,一屏 12-16 个 低,一屏 6-8 个
点击目标 较小 较大
视觉重点 图标(颜色区分) 文字(信息完整)
适用场景 浏览、探索 查找、对比

🎨 卡片设计原则

在写代码之前,我们先聊聊设计。卡片不是随便把信息堆在一起就完事了,这里面有讲究。

原则1:清晰的信息层级

一张卡片里可能有好几个信息:图标、名称、人口、地区...... 哪个最重要?

名称 > 图标 > 辅助信息

用户看卡片,第一眼是找名字。所以:

  • 名称字号最大、颜色最深

  • 图标用来快速识别(颜色是重要线索)

  • 辅助信息(人口、地区)字号小、颜色浅,不能抢戏

    ┌──────────┐
    │ ○ │ ← 图标:视觉锚点,颜色区分
    │ 汉族 │ ← 名称:最大、最显眼
    │ 12亿人 │ ← 辅助信息:小、浅,不抢戏
    └──────────┘

原则2:足够的内边距

内容不能贴边,要给它"呼吸"的空间。内边距太小,卡片显得局促;太大,浪费空间。

「民族图鉴」的卡片内边距统一用 12vp (也就是 $r('app.float.spacing_sm'))。这是一个经过权衡的值------既不挤,也不松。

原则3:统一的圆角

圆角是卡片的"性格":

  • 小圆角(4vp):严肃、正式
  • 中圆角(8vp):友好、现代
  • 大圆角(16vp+):可爱、活泼

「民族图鉴」用的是中圆角($r('app.float.radius_md')),既保持了文化应用的稳重感,又不失现代气息。

原则4:微妙的阴影

阴影是卡片"浮起来"的关键。但阴影不能太重------太重显得脏,太轻又没效果。

好的阴影应该是:淡、柔、偏下

  • 颜色:黑色,透明度 10%-15%
  • 模糊半径:8-12vp
  • 偏移:Y 轴 2-4vp,X 轴 0

这样卡片看起来像是轻轻放在页面上,而不是糊在上面。

💡 一个小细节:「民族图鉴」的网格卡片其实没有用阴影------因为卡片太多了,每张都有阴影会显得很乱。用浅色背景 + 圆角就够了。列表页整体保持干净清爽,比每张卡片都"炫技"更重要。

原则5:可识别的点击区域

卡片必须能让用户一眼看出来"这是可以点的"。怎么做?

  1. 形状暗示:圆角矩形,看起来像个按钮
  2. 颜色区分:和背景色不一样
  3. 交互反馈:点下去有反应(这个我们后面详细讲)

🛠️ 核心实现

步骤1:网格卡片实现

网格卡片是列表页默认的展示方式,4 列布局,每张卡片垂直排列:图标在上,名称在中间,人口在下面。

1.1 整体结构
typescript 复制代码
// pages/EthnicListPage.ets

@Builder
buildGridCard(ethnic: EthnicGroup, index: number): void {
  Column({ space: $r('app.float.spacing_xs') }) {
    // 1. 首字圆形图标
    Text(ethnic.name.charAt(0))
      // ... 图标样式

    // 2. 民族名称
    Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
      // ... 名称样式

    // 3. 人口信息
    Text(ethnic.population)
      // ... 辅助信息样式
  }
  .width('100%')
  .padding($r('app.float.spacing_sm'))
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_md'))
  .onClick(() => {
    // 点击跳转
  })
}

结构要点

  • Column 垂直排列三个元素
  • space 控制元素间距(这里用的是 xs,也就是 4vp)
  • width('100%') 占满 GridItem 的宽度
  • justifyContentalignItems 都居中,因为卡片内容是居中对齐的
1.2 圆形图标设计

这是整张卡片的"灵魂"------每个民族用它名字的第一个字,放在一个彩色的圆里。颜色来自民族的 emblemColor(象征色)。

typescript 复制代码
Text(ethnic.name.charAt(0))
  .fontSize($r('app.float.font_size_xxl'))    // 字号 24vp
  .fontWeight(FontWeight.Bold)                 // 粗体
  .fontColor('#FFFFFF')                        // 白色文字
  .width(44)                                   // 宽 44vp
  .height(44)                                  // 高 44vp
  .borderRadius(22)                            // 圆角 22vp(正好是宽高的一半,变成圆形)
  .backgroundColor(ethnic.emblemColor)         // 背景色用民族的象征色
  .textAlign(TextAlign.Center)                 // 文字居中

为什么用圆形?

  • 圆形柔和、友好,符合文化应用的气质
  • 在一堆方形卡片里,圆形图标是很好的视觉点缀
  • 56 种不同的颜色,用户可以靠颜色快速识别

为什么是 44vp?

  • 不能太小:小于 32vp,字就放不进去了
  • 不能太大:大于 56vp,卡片就塞不下了
  • 44vp 是一个平衡点------既看得清,又不浪费空间

💡 圆形实现技巧 :在 ArkUI 里,要做圆形,只要把 borderRadius 设成宽高的一半就行。比如宽高 44vp,圆角就是 22vp。这是通用技巧,不局限于 Text,任何组件都可以这么做。

1.3 名称展示

名称是卡片最重要的信息,要最显眼。

typescript 复制代码
Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
  .fontSize($r('app.float.font_size_sm'))   // 14vp
  .fontWeight(FontWeight.Medium)            // 中等粗细
  .fontColor($r('app.color.text_primary'))  // 主文本色
  .maxLines(1)                              // 最多一行
  .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超出显示省略号

关键点

  • maxLines(1) + textOverflow:保证名称太长时不会撑破布局
  • getLocalizedText 根据当前语言显示中文或英文名
  • FontWeight.Medium:比普通字稍粗一点,但又不至于像 Bold 那么重
1.4 辅助信息(人口)

辅助信息要"弱"一点,不能抢了名称的风头。

typescript 复制代码
Text(ethnic.population)
  .fontSize($r('app.float.font_size_xs'))   // 12vp,比名称小两号
  .fontColor($r('app.color.text_hint'))     // 提示文字色,更浅

就两行代码------字号小一点、颜色浅一点,它自然就"退居二线"了。

1.5 完整的网格卡片代码

把上面拼起来,就是完整的网格卡片:

typescript 复制代码
@Builder
buildGridCard(ethnic: EthnicGroup, index: number): void {
  Column({ space: $r('app.float.spacing_xs') }) {
    // 首字圆形图标(白色文字在有色背景上)
    Text(ethnic.name.charAt(0))
      .fontSize($r('app.float.font_size_xxl'))
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width(44)
      .height(44)
      .borderRadius(22)
      .backgroundColor(ethnic.emblemColor)
      .textAlign(TextAlign.Center)

    Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
      .fontSize($r('app.float.font_size_sm'))
      .fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Text(ethnic.population)
      .fontSize($r('app.float.font_size_xs'))
      .fontColor($r('app.color.text_hint'))
  }
  .width('100%')
  .padding($r('app.float.spacing_sm'))
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_md'))
  .onClick(() => {
    router.pushUrl({
      url: 'pages/EthnicDetailPage',
      params: { ethnicId: ethnic.id }
    });
  })
}

步骤2:列表项实现

列表项是另一种展示方式,横向排列:左边图标,中间文字,右边箭头。

2.1 整体结构
typescript 复制代码
@Builder
buildListItem(ethnic: EthnicGroup, index: number): void {
  Row({ space: $r('app.float.spacing_md') }) {
    // 1. 左边:圆形图标
    Text(ethnic.name.charAt(0))
      // ... 图标样式

    // 2. 中间:名称 + 地区+人口
    Column({ space: 4 }) {
      Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
      Text(ethnic.region + ' · ' + ethnic.population)
    }
    .layoutWeight(1)  // 占满剩余空间
    .alignItems(HorizontalAlign.Start)

    // 3. 右边:箭头
    Text('>')
      // ... 箭头样式
  }
  .width('100%')
  .height(56)
  .padding({ left: $r('app.float.spacing_md'), right: $r('app.float.spacing_sm') })
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_md'))
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    // 点击跳转
  })
}

结构要点

  • Row 水平排列三个区域
  • layoutWeight(1):中间区域占满剩余空间,把图标和箭头挤到两边
  • height(56):固定高度 56vp,这是列表项的经典高度(移动端推荐 48-64vp)
  • alignItems(VerticalAlign.Center):垂直居中
2.2 左侧图标

和网格卡片的图标类似,但尺寸稍小一点(因为列表项高度有限)。

typescript 复制代码
Text(ethnic.name.charAt(0))
  .fontSize($r('app.float.font_size_xl'))   // 20vp,比网格卡片小一号
  .fontWeight(FontWeight.Bold)
  .fontColor('#FFFFFF')
  .width(40)                                // 40vp,比网格卡片的 44vp 小一点
  .height(40)
  .borderRadius(20)
  .backgroundColor(ethnic.emblemColor)
  .textAlign(TextAlign.Center)

为什么小一点?因为列表项高度只有 56vp,图标太大了上下留白就不够了。40vp 配 56vp 的高度,上下各 8vp 的留白,刚刚好。

2.3 中间信息区

中间是主要信息区,两行:第一行名称,第二行地区和人口。

typescript 复制代码
Column({ space: 4 }) {
  // 第一行:名称
  Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
    .fontSize($r('app.float.font_size_md'))    // 16vp,比网格卡片大
    .fontWeight(FontWeight.Medium)
    .fontColor($r('app.color.text_primary'))

  // 第二行:地区 · 人口
  Text(ethnic.region + ' · ' + ethnic.population)
    .fontSize($r('app.float.font_size_xs'))
    .fontColor($r('app.color.text_hint'))
}
.layoutWeight(1)                                 // 占满剩余空间
.alignItems(HorizontalAlign.Start)               // 左对齐

注意几个细节

  1. 名称字号更大:列表模式下,信息空间更充足,名称可以用 16vp(网格是 14vp)
  2. 两行间距 4vp:紧凑但不拥挤
  3. 地区和人口用"·"分隔:这是很常见的信息分隔方式,简洁优雅
  4. layoutWeight(1) + alignItems(HorizontalAlign.Start):占满空间,内容左对齐

💡 layoutWeight 是什么?

它和 Web 开发里 Flexbox 的 flex: 1 是一个意思------"把剩余空间都给我"。

在 Row 里,左边图标占 40vp,右边箭头占一点,中间的 layoutWeight(1) 就把剩下的空间全占了。

这样不管屏幕多宽,中间的文字区域总是能铺满。

2.4 右侧箭头

一个小小的 ">" 符号,告诉用户"点这里可以进去看更多"。

typescript 复制代码
Text('>')
  .fontSize($r('app.float.font_size_lg'))     // 18vp
  .fontColor($r('app.color.text_hint'))       // 浅灰色

别看它小,作用可大了------这是一个视觉暗示,用户看到箭头就知道"这是可以点的,点了会到下一页"。

为什么用 ">" 而不是其他符号?因为这是移动端的通用约定,用户已经形成条件反射了。不用重新发明轮子。


步骤3:点击反馈设计

这是交互体验的关键。用户点了卡片,卡片得有反应------不然用户不知道自己点到了没有。

3.1 常见的点击反馈方式
反馈方式 效果 适用场景
透明度变化 按下时变半透明 最简单,通用
缩放变化 按下时缩小一点 按钮、卡片,有"按下去"的感觉
颜色变化 按下时变深色 扁平按钮
水波纹效果 从点击位置扩散波纹 Material Design 风格

「民族图鉴」用的是 透明度 + 缩放 的组合,既有"按下去"的感觉,又不会太夸张。

3.2 实现原理

核心思路是:用一个状态变量记录当前按下的是哪张卡片,根据这个状态改变卡片的样式,再用动画过渡

typescript 复制代码
// 状态变量:当前按下的卡片索引,-1 表示没有按下
@State pressedIndex: number = -1;

卡片按下时,把 pressedIndex 设成当前索引;松开时,设回 -1。

typescript 复制代码
.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Down) {
    this.pressedIndex = index;          // 按下:记录索引
  } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
    this.pressedIndex = -1;             // 松开/取消:恢复
  }
})

然后卡片的样式根据 pressedIndex 动态变化:

typescript 复制代码
.opacity(this.pressedIndex === index ? 0.8 : 1)
.scale({
  x: this.pressedIndex === index ? 0.95 : 1,
  y: this.pressedIndex === index ? 0.95 : 1
})
.animation({
  duration: 150,
  curve: Curve.EaseInOut
})
  • 按下时:透明度 0.8,缩小到 0.95 倍
  • 松开时:恢复正常
  • 动画时长 150ms,用 EaseInOut 曲线

为什么是 0.8 和 0.95?

  • 透明度 0.8:变化明显但不会"闪瞎眼"
  • 缩放 0.95:缩小 5%,有"按下去"的感觉,但不会缩得太厉害
  • 这两个值是经过大量实践验证的"黄金比例",大多数场景都适用
3.3 为什么用 onTouch 而不是 onClick?

因为 onClick 只有"点击完成"那一瞬间触发,没法知道"什么时候按下的"、"什么时候松开的"。

要做按下效果,必须监听 onTouch 事件,区分 Down(按下)、Up(松开)、Cancel(取消,比如手指滑出去了)。

复制代码
手指按下 → TouchType.Down → 卡片缩小
手指抬起 → TouchType.Up → 卡片恢复 + 触发 onClick
手指滑走 → TouchType.Cancel → 卡片恢复(不触发 onClick)

这才是完整的交互流程。

💡 一个容易踩的坑onTouchonClick 可以同时存在。onTouch 负责视觉反馈,onClick 负责实际的跳转逻辑。两者互不干扰,各司其职。

3.4 为什么用 pressedIndex 而不是给每张卡片单独加状态?

因为 56 张卡片,如果每张都有一个 isPressed 状态变量,那就是 56 个状态变量,太浪费了。

实际上,用户同一时间只能按下一张卡片 。所以用一个变量 pressedIndex 记录"当前按下的是哪张"就够了。

按下第 3 张 → pressedIndex = 3 → 第 3 张卡片判断 "我是不是 pressedIndex?是 → 缩小"

松开 → pressedIndex = -1 → 所有卡片都判断 "我不是 → 正常"

这是一个很经典的优化思路------用单个状态变量管理列表中单项的选中/按下状态


步骤4:无障碍适配

这是很多开发者容易忽略的一点,但其实非常重要。

无障碍(Accessibility,简称 A11y),简单说就是让视障用户也能正常使用应用。视障用户通过"屏幕阅读器"来操作手机------手指摸到哪里,手机就把那里的内容读出来。

4.1 为什么需要无障碍适配?

如果不做适配,屏幕阅读器读到我们的卡片会怎么样?

它会一个个读:

  • "汉"(图标里的文字)
  • "汉族"(名称)
  • "约12亿人"(人口)

用户听到的是三段独立的文字,根本不知道这是一个整体。

正确的做法是:告诉屏幕阅读器,"这一整块是一个东西,它叫'汉族,人口约12亿'"。

4.2 实现方法:accessibilityText

在 ArkUI 里,只要给组件加一个 accessibilityText 属性就行:

typescript 复制代码
// 网格卡片
.accessibilityText(`${ethnic.name}, ${ethnic.population}`)

// 列表项
.accessibilityText(`${ethnic.name}, ${ethnic.region}, ${ethnic.population}`)

就这么一行代码,屏幕阅读器就会把整张卡片当作一个整体来读,读的内容就是你设置的这段文字。

怎么写 accessibilityText?

  • 简洁明了:不要太长,重点信息说清楚
  • 用逗号分隔:屏幕阅读器会在逗号那里停顿一下
  • 按重要性排序:最重要的放前面

比如列表项的:"汉族, 主要分布在华北, 约12亿人"

用户先听到"汉族"(知道是什么),再听到地区和人口(辅助信息)。

💡 无障碍不是"可选功能"

很多开发者觉得"我身边没人用屏幕阅读器,做这个干嘛"------这是不对的。

第一,这是社会责任。第二,很多国家和地区(包括中国)的应用商店对无障碍有要求,不符合要求可能上不了架。第三,无障碍做好了,对普通用户体验也有提升(比如更清晰的语义、更合理的结构)。

从第一天就考虑无障碍,比后面补要容易得多。


步骤5:卡片性能优化

56 张卡片,说多不多,说少不少。但如果每张卡片都做得很重,滚动起来还是会卡的。

5.1 简化组件层级

组件层级越深,渲染越慢。能一层解决的,不要用两层。

反例:为了加个圆角,套了三层容器

typescript 复制代码
Stack() {
  Column() {
    // ... 内容
  }
}
.borderRadius(12)
.backgroundColor('#fff')

正例:直接给内容容器加圆角和背景

typescript 复制代码
Column() {
  // ... 内容
}
.borderRadius(12)
.backgroundColor('#fff')

「民族图鉴」的卡片就很克制------网格卡片只有一层 Column,里面三个 Text。列表项只有一层 Row,里面三个子元素。

5.2 减少阴影和模糊

阴影、模糊这些效果,看起来好看,但非常耗性能。尤其是在列表里,几十张卡片都有阴影,GPU 压力会很大。

「民族图鉴」的策略是:

  • 网格卡片:不用阴影,只用圆角和背景色区分
  • 首页精选卡片:也不用阴影,保持简洁
  • 独立的大卡片(比如冷知识卡片):可以加一点淡阴影,因为数量少

能用颜色区分的,就不要用阴影。 这是性能和美观的一个重要权衡。

5.3 文字渲染优化

文字是卡片里最主要的内容。文字渲染也有优化空间:

  1. 减少 maxLines 的使用maxLines 会触发额外的文字计算。但该用还是要用,总比文字溢出好。
  2. 固定尺寸的文字优先:如果知道文字多长,设个固定宽度,比自适应更快。
  3. 避免动态计算字体:字体大小尽量用固定值,不要在渲染时动态计算。
5.4 图片优化(扩展阅读)

「民族图鉴」的卡片用的是文字图标(首字 + 彩色背景),没有用图片。这其实也是一种优化------文字渲染比图片快多了。

如果你的卡片里有图片,要注意:

  • 图片尺寸要合适,不要用大图缩小显示
  • 图片格式用 WebP(体积小)
  • 图片懒加载(LazyForEach 已经帮你做了一部分)
  • 列表里的图片不要加圆角和阴影(图片做圆角非常耗性能)

🎯 不同场景的卡片变体

「民族图鉴」里不只有列表卡片,还有好几种卡片。虽然外观不同,但设计理念是相通的。

变体1:首页精选卡片

首页横向滚动的精选卡片,比列表页的网格卡片稍大一点:

typescript 复制代码
// pages/Index.ets

@Builder
buildFeaturedCard(ethnic: EthnicGroup): void {
  Column({ space: $r('app.float.spacing_sm') }) {
    Text(ethnic.name.charAt(0))
      .fontSize($r('app.float.font_size_xxxl'))  // 更大的字号
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width(56)                                 // 更大的图标 56vp
      .height(56)
      .borderRadius(28)
      .backgroundColor(ethnic.emblemColor)
      .textAlign(TextAlign.Center)

    Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
      .fontSize($r('app.float.font_size_sm'))
      .fontWeight(FontWeight.Medium)
      .fontColor($r('app.color.text_primary'))
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Text(ethnic.population)
      .fontSize($r('app.float.font_size_xs'))
      .fontColor($r('app.color.text_hint'))
  }
  .width(80)                                      // 固定宽度 80vp
  .padding($r('app.float.spacing_sm'))
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .onClick(() => {
    router.pushUrl({ url: 'pages/EthnicDetailPage', params: { ethnicId: ethnic.id } });
  })
}

和列表页网格卡片的区别

  • 图标更大(56vp vs 44vp)
  • 卡片宽度固定(80vp vs 自适应)
  • 圆角更大(radius_lg vs radius_md)
  • 没有点击反馈动画(因为横向滚动,优先级低一些)

设计思路是一致的,只是尺寸参数不同。

变体2:快捷入口卡片

首页的快捷入口,也是卡片的一种:

typescript 复制代码
@Builder
buildQuickEntryItem(icon: string, label: string, iconColor: ResourceStr, onClickAction: () => void): void {
  Column({ space: 8 }) {
    Text(icon)
      .fontSize(24)
      .fontColor('#FFFFFF')
      .width(44)
      .height(44)
      .borderRadius(22)
      .backgroundColor(iconColor)
      .textAlign(TextAlign.Center)

    Text(label)
      .fontSize($r('app.float.font_size_xs'))
      .fontColor($r('app.color.text_primary'))
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .layoutWeight(1)
  .height(96)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .border({ width: 1, color: $r('app.color.border_color') })
  .onClick(onClickAction)
}

结构几乎一模一样:图标在上,文字在下,居中对齐,圆角背景。

你看,掌握了卡片设计的基本方法,各种各样的卡片都能信手拈来。


🐛 常见问题与解决方案

问题1:卡片文字太长,撑破布局

现象:有些民族名字比较长(比如"柯尔克孜族"有 5 个字),在小卡片里显示不下,要么换行,要么溢出。

解决方案

方案1:单行 + 省略号(推荐)

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

最常用的做法。超出就显示省略号,用户点进去看详情就好了。

方案2:自动缩小字号

typescript 复制代码
Text(name)
  .maxLines(1)
  .minFontSize(10)
  .maxFontSize(14)

文字长了自动缩小字号,保证能显示完整。但缩得太小也看不清,所以要设 minFontSize

方案3:自适应宽度

如果是横向列表,卡片宽度可以根据内容自适应。但这样卡片宽度不一致,看起来不齐整。

「民族图鉴」用的是方案1------单行省略号。因为 56 个民族名字大多数是 2-4 个字,只有少数几个长名字会显示省略号,影响不大。保持整齐划一更重要。


问题2:卡片点击不灵敏

现象:用户点了卡片,有时候没反应。

可能的原因和解决方案

原因1:点击区域太小

卡片最小点击区域建议不小于 44x44vp。这是 Apple 和 Google 都推荐的最小点击尺寸------人的手指大概这么宽,再小就容易点不中。

检查一下你的卡片,如果小于 44vp,赶紧加大。

原因2:被其他组件挡住了

比如卡片上面盖了个透明的组件,把点击事件截走了。

检查组件层级,确保卡片在最上层。或者给上面的组件加 .hitTestBehavior(HitTestMode.Transparent),让它不拦截点击事件。

原因3:onTouch 里逻辑写错了

比如 TouchType.Cancel 没有处理,手指滑出去再滑回来,状态就乱了。

确保三种状态都处理:

typescript 复制代码
.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Down) {
    // 按下
  } else if (event.type === TouchType.Up) {
    // 松开
  } else if (event.type === TouchType.Cancel) {
    // 取消(手指滑出去了)
  }
})

问题3:按下效果太生硬

现象:点下去卡片"啪"一下就缩小了,松开又"啪"一下弹回来,很突兀。

解决方案:加动画!

typescript 复制代码
.animation({
  duration: 150,
  curve: Curve.EaseInOut
})

就这三行代码,体验立刻上一个档次。

动画参数怎么选?

  • 时长:100-200ms 比较合适。太短(<50ms)看不出效果,太长(>300ms)觉得拖沓。
  • 曲线EaseInOut 是最自然的------开始慢,中间快,结束慢。比线性的舒服多了。

问题4:卡片太多,滚动卡顿

现象:快速滚动列表时,掉帧,不流畅。

排查思路

  1. 先用 LazyForEach:如果还在用 ForEach,先换成 LazyForEach。这是最有效的优化。
  2. 检查组件层级:每张卡片嵌套了几层?尽量控制在 2-3 层以内。
  3. 检查阴影和模糊:卡片是不是都加了阴影?试试去掉阴影,看会不会流畅很多。
  4. 检查图片:有图片的话,看看是不是太大了,有没有做懒加载。
  5. 检查 onScroll 回调:是不是在 onScroll 里做了什么重操作?

「民族图鉴」的 56 张卡片,因为做得很"轻"(每层都是简单的 Text 和 Column/Row,没有阴影,没有图片),滚动起来非常流畅。

💡 性能优化的黄金法则:先测,再优化。不要凭感觉优化,用 DevEco Studio 的性能分析工具看看瓶颈到底在哪里,再针对性地优化。很多时候你以为是卡片的问题,其实是别的地方。


问题5:暗色模式下卡片看不清

现象:用户手机切到暗色模式,卡片还是浅色的,要么看不清,要么很刺眼。

解决方案:使用资源系统定义颜色,而不是写死颜色值。

「民族图鉴」就是这么做的:

typescript 复制代码
// ✅ 好:用资源,自动适配暗色模式
.backgroundColor($r('app.color.card_background'))

// ❌ 不好:写死白色,暗色模式下很刺眼
.backgroundColor('#FFFFFF')

在资源文件里定义两套颜色:

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

系统会自动根据当前模式选择对应的颜色。不用写任何判断代码。


🧠 进阶拓展:卡片设计的深度探索

6.1 卡片设计的视觉层次

一张好的卡片,用户扫一眼就能抓住重点。这靠的不是"把信息都放上去",而是"信息有层次"------重要的突出,次要的弱化。

6.1.1 信息的三级层次
层级 作用 视觉处理 「民族图鉴」示例
第一级:主标题 最核心的信息,用户一眼看到 最大字号、加粗、最深颜色 民族名称(16fp,加粗,#1A1A1A)
第二级:副标题/辅助信息 补充说明,帮助理解 中等字号、常规字重、次深颜色 人口数量(12fp,常规,#666666)
第三级:装饰/标签 额外信息,可有可无 最小字号、最浅颜色、小标签 地区标签(10fp,#999999)

三级层次的关键是对比------大小对比、颜色对比、粗细对比。对比越明显,层次越清晰。

typescript 复制代码
// 错误:所有文字差不多大,没有层次
Column() {
  Text('傣族')
    .fontSize(14)
  Text('人口约130万')
    .fontSize(13)
  Text('主要分布在云南')
    .fontSize(12)
}

// 正确:大小分明,层次清晰
Column() {
  Text('傣族')
    .fontSize(18)                    // 大
    .fontWeight(FontWeight.Bold)     // 粗
    .fontColor('#1A1A1A')            // 深
  Text('人口约130万')
    .fontSize(14)                    // 中
    .fontColor('#666666')            // 中
    .margin({ top: 4 })
  Text('主要分布在云南')
    .fontSize(12)                    // 小
    .fontColor('#999999')            // 浅
    .margin({ top: 2 })
}

💡 层次设计的"三秒钟法则"

用户扫一眼卡片(大约3秒钟),能不能说出这是什么、有什么关键信息?

如果能,说明层次设计得好;如果不能,说明信息太乱,需要精简和分层。

6.1.2 视觉权重的分配

不是所有信息都同等重要。要根据重要性分配"视觉权重":

  • 大小:越重要越大
  • 颜色:越重要越深(对比度越高)
  • 粗细:越重要越粗
  • 位置:越重要越靠上、越靠左(阅读顺序)
  • 空间:越重要周围留白越多

「民族图鉴」列表卡片的视觉权重分配:

  1. 民族名称(最高权重):最大、最粗、最左、最上
  2. 民族图标(次高权重):最大的视觉元素,在最左边
  3. 人口数量(中等权重):在名称下面,字小一点
  4. 地区标签(最低权重):在最右边,字最小、颜色最浅
6.1.3 不要让卡片太"满"

新手最容易犯的错误就是------把所有信息都塞进卡片里,生怕用户看不到。结果卡片挤得满满的,用户反而什么都记不住。

留白的价值

  • 留白让内容更突出(反衬)
  • 留白给眼睛"休息"的空间
  • 留白让卡片显得更高级、更精致

💡 设计的减法原则

设计不是做加法(不停加东西),而是做减法------不断问自己:这个信息能不能去掉?能不能放到详情页?能去掉的都去掉,只留下最核心的。

卡片只是"入口",不是"全部"。想知道详细内容?点进去看详情页。


6.2 网格卡片 vs 列表卡片:怎么选?

「民族图鉴」有两种卡片样式:网格模式和列表模式。这两种分别适合什么场景?该怎么选?

6.2.1 网格卡片的特点
复制代码
┌─────┐ ┌─────┐ ┌─────┐
│ 图  │ │ 图  │ │ 图  │
│ 标题│ │ 标题│ │ 标题│
└─────┘ └─────┘ └─────┘

优点

  • 信息密度高,一屏能看到很多
  • 图片展示充分,视觉效果好
  • 适合"逛"的体验,用户慢慢看
  • 整齐划一,有韵律感

缺点

  • 文字空间有限,不能放太多信息
  • 卡片大小固定,内容多了放不下
  • 查找效率低,找特定内容要一个个扫

适合场景

  • 内容以图片为主
  • 浏览型、探索型场景
  • 用户没有明确目标,就是随便看看
  • 商品、图片、视频类内容
6.2.2 列表卡片的特点
复制代码
┌─────────────────────────┐
│  图  标题                │
│  标  副标题              │
│  标  辅助信息            │
└─────────────────────────┘

优点

  • 信息量大,可以放很多文字
  • 阅读体验好,内容看得全
  • 查找效率高,从上往下扫很快
  • 可以放更多操作按钮

缺点

  • 信息密度低,一屏没几条
  • 图片展示空间小,视觉冲击力弱
  • 比较单调,不如网格"好看"

适合场景

  • 内容以文字为主
  • 查找型、工具型场景
  • 用户有明确目标,想快速找到
  • 消息、设置、通讯录类内容
6.2.3 「民族图鉴」为什么两种都做?

因为不同用户有不同的偏好:

  • 有的用户喜欢"逛"------用网格模式,慢慢看图片
  • 有的用户喜欢"找"------用列表模式,快速定位

两种模式都提供,用户自己选。这叫"用户控制权"------把选择权交给用户,比我们替用户做决定更好。

💡 什么时候该做视图切换?

  • 如果你的内容既可以浏览又可以查找
  • 如果你的用户群差异大(有的喜欢看图片,有的喜欢看文字)
  • 如果你的内容数量多(多于20条)

满足以上条件,做个视图切换功能,用户会感谢你的。


6.3 卡片的多种变体

卡片不只有"图片+文字"这一种形式。根据内容和场景的不同,卡片有很多变体。

6.3.1 大图卡片(Hero Card)

图片占大部分区域,文字在图片下面或浮在图片上。

复制代码
┌─────────────────┐
│                 │
│      大图       │
│                 │
├─────────────────┤
│ 标题            │
│ 副标题          │
└─────────────────┘

特点

  • 视觉冲击力强,很吸引眼球
  • 图片是主角,文字是配角
  • 适合内容优质、图片好看的场景

适用场景

  • 精选推荐、编辑推荐
  • 头条、置顶内容
  • 商品详情、文章详情
6.3.2 小图卡片(Thumbnail Card)

小图在左边,文字在右边,整体比较紧凑。

复制代码
┌─────────────────────────┐
│ ┌──┐ 标题               │
│ │图│ 副标题             │
│ └──┘ 辅助信息           │
└─────────────────────────┘

特点

  • 紧凑,信息密度高
  • 图文搭配,不单调
  • 适合列表浏览

适用场景

  • 搜索结果列表
  • 消息列表
  • 普通内容列表

「民族图鉴」的列表模式就是这种小图卡片。

6.3.3 纯文字卡片(Text Card)

没有图片,全是文字。靠排版和布局营造层次。

复制代码
┌─────────────────────────┐
│ 标题                    │
│ 副标题/摘要             │
│                         │
│ 标签1 标签2  时间       │
└─────────────────────────┘

特点

  • 最简洁,加载最快
  • 完全靠文字排版体现层次
  • 适合文字为主的内容

适用场景

  • 纯文字内容(文章、消息)
  • 加载速度优先的场景
  • 没有合适图片的内容
6.3.4 图文混排卡片

图片和文字穿插排列,布局比较自由。

复制代码
┌─────────────────────────┐
│ 标题                    │
│ ┌──┐  一段文字描述      │
│ │图│  ................  │
│ └──┘  ................  │
│                         │
│ [按钮] [按钮]           │
└─────────────────────────┘

特点

  • 内容丰富,形式多样
  • 灵活,可以根据内容调整布局
  • 设计难度高,容易乱

适用场景

  • 信息流、动态流
  • 复杂内容的展示
  • 社交类产品

💡 卡片选型的原则

  1. 内容决定形式:有好看的图就用大图,没图就用纯文字,不要为了用卡片而硬加图
  2. 场景决定形式:浏览用大图,查找用小图,阅读用文字
  3. 保持统一:同一个列表里,卡片样式要统一,不要有的大图有的小图

卡片只是容器,内容才是主角。不要喧宾夺主。


6.4 卡片的交互反馈

卡片不只是用来"看"的,还是用来"点"的。点击的反馈质量,直接影响用户体验。

6.4.1 按下反馈的三种程度
程度 效果 适用场景
轻度 透明度变化(0.8-0.9) 弱操作、次要卡片
中度 透明度 + 缩放(95-97%) 普通卡片(最常用)
重度 缩放 + 颜色变化 + 阴影 重要按钮、主卡片

「民族图鉴」用的是中度------透明度 0.9 + 缩放 0.96。既有手感,又不会太夸张。

6.4.2 滑动操作

除了点击,卡片还可以有滑动操作------左滑显示删除、收藏等按钮。

复制代码
正常状态:
┌─────────────────────────┐
│  卡片内容                │
└─────────────────────────┘

左滑后:
┌──────────────────┬──────┐
│  卡片内容        │ 删除  │
└──────────────────┴──────┘

鸿蒙的 List 组件支持滑动操作,可以通过 swipeAction 属性实现:

typescript 复制代码
ListItem() {
  // 卡片内容
}
.swipeAction({
  end: {
    builder: () => {
      Text('删除')
        .width(80)
        .height('100%')
        .backgroundColor('#F56C6C')
        .fontColor('#FFFFFF')
        .textAlign(TextAlign.Center)
        .onClick(() => {
          // 处理删除
        })
    }
  }
})

滑动操作虽然酷,但不要滥用:

  • 只给最常用的操作(比如删除)
  • 要有提示,让用户知道可以滑
  • 要有撤销机制,防止误删
6.4.3 长按菜单

长按卡片弹出操作菜单,也是一种常见的交互。

复制代码
┌─────────────────────────┐
│  卡片内容                │
└─────────────────────────┘
          ┌───────┐
          │ 收藏   │
          ├───────┤
          │ 分享   │
          ├───────┤
          │ 删除   │
          └───────┘

鸿蒙可以用 bindMenubindContextMenu 实现:

typescript 复制代码
CardComponent()
  .bindContextMenu(
    @Builder
    () => {
      Menu() {
        MenuItem({
          content: '收藏',
          icon: $r('app.media.ic_star'),
          value: 'favorite'
        })
        MenuItem({
          content: '分享',
          icon: $r('app.media.ic_share'),
          value: 'share'
        })
        MenuItem({
          content: '删除',
          icon: $r('app.media.ic_delete'),
          value: 'delete'
        })
      }
    },
    ResponseType.LongPress
  )

💡 交互设计的原则

  1. 可发现:用户要知道"这里可以点/可以滑/可以长按"
  2. 即时反馈:操作了立刻有反应
  3. 可撤销:重要操作(比如删除)要能撤销
  4. 一致性:整个 App 的交互要统一,不要有的卡片能滑有的不能

好的交互,用户用起来觉得"顺手"------想都不用想,自然就会了。


6.5 无障碍适配(Accessibility)

很多开发者容易忽略无障碍适配------也就是让视力障碍、运动障碍的用户也能正常使用 App。这不仅是社会责任,其实也是提升所有用户体验的过程。

6.5.1 为什么要做无障碍?
  • 用户群大:中国有超过 8500 万残疾人,其中视力障碍 1700 多万
  • 法律要求:越来越多的国家和地区要求 App 必须符合无障碍标准
  • 体验提升:无障碍做好了,所有用户的体验都会提升(比如大字体、高对比度)
  • 品牌形象:关注无障碍的公司,用户会觉得更有温度
6.5.2 卡片的无障碍适配要点

1. 内容描述(contentDescription)

视障用户用读屏软件(比如 TalkBack),它会读出控件的描述。如果卡片只有图片没有文字描述,用户就不知道这是什么。

typescript 复制代码
// ❌ 不好:只有图片,读屏不知道是什么
Image(coverImage)

// ✅ 好:加上内容描述
Image(coverImage)
  .accessibility({
    description: `${ethnic.name},人口${ethnic.population},主要分布在${ethnic.region}`
  })

2. 可聚焦

整个卡片要能被读屏软件聚焦,点击操作要能被触发。

typescript 复制代码
Column() {
  // 卡片内容
}
.accessibility({
  role: AccessibilityRole.Button,  // 声明是按钮
  hint: '点击查看详情',              // 操作提示
  description: '傣族卡片'           // 内容描述
})

3. 对比度

文字和背景的对比度要足够,不然视力不好的用户看不清。

WCAG 标准要求:

  • 正文文本:对比度至少 4.5:1

  • 大文本(18pt 以上或 14pt 加粗):对比度至少 3:1

    浅色背景用深文字:
    背景 #FFFFFF,文字 #333333 → 对比度约 12:1 ✅
    背景 #FFFFFF,文字 #999999 → 对比度约 2.8:1 ❌ 不够

    深色背景用浅文字:
    背景 #1A1A1A,文字 #FFFFFF → 对比度约 16:1 ✅
    背景 #1A1A1A,文字 #666666 → 对比度约 2.3:1 ❌ 不够

4. 点击区域足够大

前面说过的 44vp 最小点击区域,对运动障碍的用户尤其重要------他们可能手抖,小了点不准。

6.5.3 「民族图鉴」的无障碍实践

虽然我们是个小项目,但基本的无障碍还是要做:

  • 所有图片加 contentDescription
  • 重要控件加 accessibility 描述
  • 颜色对比度达标
  • 最小点击区域 44vp
  • 支持系统字体缩放

💡 无障碍不是"额外功能"

很多人觉得无障碍是"给残疾人用的",是锦上添花的东西。但实际上,无障碍是基础体验的一部分。

比如:

  • 大字体不只是给视力障碍的人用,老人也需要
  • 高对比度不只是给弱视的人用,阳光下看手机也需要
  • 大按钮不只是给运动障碍的人用,手忙的时候也需要

做好无障碍,受益的是所有用户。


6.6 卡片的性能优化(进阶)

前面我们提到了一些性能优化的点。这里再深入聊一聊。

6.6.1 为什么列表会卡?

手机屏幕每秒钟要刷新 60 次(60fps),也就是说每 16.6 毫秒就要渲染一帧。如果某一帧的渲染时间超过 16.6 毫秒,就会"掉帧"------用户感觉到卡顿。

卡片渲染慢的常见原因:

  1. 布局计算复杂:嵌套太深,每次布局都要算很久
  2. 绘制耗时:阴影、模糊、圆角、渐变...... 这些效果都要花时间绘制
  3. 图片加载:图片太大,解码和上传纹理都需要时间
  4. 频繁创建销毁:用 ForEach 的话,一开始创建所有卡片,内存占用大
  5. 状态更新频繁:短时间内多次更新状态,触发多次重渲染
6.6.2 优化手段清单
优化点 做法 效果
懒加载 用 LazyForEach 代替 ForEach ⭐⭐⭐ 效果最大
简化结构 减少嵌套层级,尽量扁平 ⭐⭐⭐ 效果明显
减少阴影 用背景色/边框代替阴影 ⭐⭐ 效果不错
图片优化 尺寸合适、格式优化、懒加载 ⭐⭐ 有图才需要
减少动画 非必要的动画就去掉 ⭐ 看情况
复用组件 抽取公共组件,减少重复代码 --- 代码层面
状态下放 状态尽量放在子组件,减少重渲染范围 ⭐⭐ 架构层面
6.6.3 「民族图鉴」的性能表现

我们的卡片为什么流畅?因为:

  1. 结构简单:每张卡片只有 2-3 层嵌套
  2. 没有图片:用文字图标代替图片,省了图片加载的时间
  3. 没有阴影:用背景色区分,不用阴影
  4. LazyForEach:列表用懒加载,只创建可见的

56 张卡片,每张都很"轻",滚动起来当然流畅。

💡 性能优化的正确姿势

  1. 先测量:用 DevEco Studio 的 Profiler 工具测一下,看看到底卡不卡,瓶颈在哪
  2. 再优化:针对瓶颈优化,不要凭感觉优化
  3. 再测量:优化完再测一遍,看有没有效果

不要上来就"我觉得会卡",然后做一堆优化。很多时候你担心的问题根本不是问题。

真正的性能问题,往往出在你想不到的地方。


📝 本章小结

核心知识点

本文深入讲解了列表项卡片的设计与实现,从设计原则到代码细节:

1. 卡片设计原则

  • 清晰的信息层级:名称 > 图标 > 辅助信息
  • 足够的内边距:给内容"呼吸"的空间
  • 统一的圆角:决定卡片的"性格"
  • 微妙的阴影:淡、柔、偏下
  • 可识别的点击区域:让用户知道"这能点"

2. 网格卡片实现

  • 结构:Column 垂直排列(图标 + 名称 + 人口)
  • 圆形图标:宽高相等,borderRadius 设为一半
  • 名称:最大、最粗、最深
  • 辅助信息:字号小、颜色浅
  • 整体:居中对齐,圆角背景

3. 列表项实现

  • 结构:Row 水平排列(左图标 + 中间信息 + 右箭头)
  • 中间区域:layoutWeight(1) 占满剩余空间
  • 高度固定:56vp(移动端推荐高度)
  • 右箭头:视觉暗示"可点击、可进入"

4. 点击反馈设计

  • 原理:pressedIndex 状态变量 + onTouch 事件 + 属性动画
  • 效果:透明度 0.8 + 缩放 0.95,组合反馈
  • 为什么用 onTouch:因为 onClick 只有完成时触发,没有按下/松开的过程
  • 性能优化:用一个状态变量管理所有卡片的按下状态

5. 无障碍适配

  • 为什么重要:社会责任 + 应用商店要求 + 提升整体体验
  • 怎么实现:加一行 accessibilityText
  • 怎么写:简洁明了,按重要性排序,用逗号分隔

6. 性能优化

  • 简化组件层级:能一层解决的,不要用两层
  • 减少阴影模糊:能用颜色区分的,就不要用阴影
  • 文字渲染优化:减少不必要的动态计算
  • 图片优化:尺寸合适、格式优化、懒加载

最佳实践总结

卡片信息层级:主信息 > 次信息 > 辅助信息

复制代码
主信息:最大、最粗、最深  ✅
次信息:正常大小          ✅
辅助信息:最小、最浅      ✅

圆形实现:宽高相等 + borderRadius = 一半

typescript 复制代码
.width(44)
.height(44)
.borderRadius(22)  // 44 / 2 = 22

点击反馈三件套:onTouch + 状态变量 + animation

typescript 复制代码
@State pressedIndex: number = -1;

// 触摸事件
.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Down) {
    this.pressedIndex = index;
  } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
    this.pressedIndex = -1;
  }
})

// 视觉效果 + 动画
.opacity(this.pressedIndex === index ? 0.8 : 1)
.scale({ x: this.pressedIndex === index ? 0.95 : 1, y: this.pressedIndex === index ? 0.95 : 1 })
.animation({ duration: 150, curve: Curve.EaseInOut })

无障碍一定要做,哪怕只加 accessibilityText

typescript 复制代码
.accessibilityText(`${name}, ${description}`)

列表项经典高度:48-64vp

  • 太矮:点不中
  • 太高:一屏看不了几个
  • 56vp 是黄金高度

卡片尽量"轻":少嵌套、少阴影、少图片

卡片越简单,滚动越流畅。把复杂度留给详情页,列表页要的就是"快"和"顺"。


下一篇预告

列表页的框架有了,列表项的卡片也做好了。接下来,就该进入最重要的页面------民族详情页了。

下一篇(第19篇)我们将讲解民族详情页------顶部背景与信息架构

  • 详情页的整体信息架构设计
  • 顶部大图背景的实现(rawfile 图片引用)
  • 半透明遮罩层与文字可读性
  • 沉浸式状态栏与滚动联动
  • 返回按钮与分享按钮的设计

详情页是用户停留时间最长、信息密度最高的页面,也是最考验设计功力的页面。我们一步步来,从顶部开始。


🔗 相关链接


💡 提示:卡片设计看似简单,其实学问很大。同样是放一张图、两行字,为什么有的 App 看起来就高级,有的就很土?差别全在细节里------内边距多 2vp 少 2vp、字号大一号小一号、颜色深一点浅一点、圆角大一点小一点...... 这些细微的差别累积起来,就是"精致"和"粗糙"的差距。做卡片设计,要有"像素眼"------对着设计稿,一个像素一个像素地对齐。刚开始可能觉得麻烦,但时间长了,你会形成自己的审美和直觉,看到一张卡片就知道"哪里不对"。这就是设计师和普通开发者的区别。