
📖 引言
如果说列表页是应用的"货架",那每一张卡片就是货架上的"商品包装"。
包装好不好看,直接决定了用户愿不愿意拿起来看看。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:网格卡片实现
网格卡片是列表页默认的展示方式,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 的宽度justifyContent和alignItems都居中,因为卡片内容是居中对齐的
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) // 左对齐
注意几个细节:
- 名称字号更大:列表模式下,信息空间更充足,名称可以用 16vp(网格是 14vp)
- 两行间距 4vp:紧凑但不拥挤
- 地区和人口用"·"分隔:这是很常见的信息分隔方式,简洁优雅
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)
这才是完整的交互流程。
💡 一个容易踩的坑 :
onTouch和onClick可以同时存在。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 文字渲染优化
文字是卡片里最主要的内容。文字渲染也有优化空间:
- 减少 maxLines 的使用 :
maxLines会触发额外的文字计算。但该用还是要用,总比文字溢出好。 - 固定尺寸的文字优先:如果知道文字多长,设个固定宽度,比自适应更快。
- 避免动态计算字体:字体大小尽量用固定值,不要在渲染时动态计算。
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:卡片太多,滚动卡顿
现象:快速滚动列表时,掉帧,不流畅。
排查思路:
- 先用 LazyForEach:如果还在用 ForEach,先换成 LazyForEach。这是最有效的优化。
- 检查组件层级:每张卡片嵌套了几层?尽量控制在 2-3 层以内。
- 检查阴影和模糊:卡片是不是都加了阴影?试试去掉阴影,看会不会流畅很多。
- 检查图片:有图片的话,看看是不是太大了,有没有做懒加载。
- 检查 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 视觉权重的分配
不是所有信息都同等重要。要根据重要性分配"视觉权重":
- 大小:越重要越大
- 颜色:越重要越深(对比度越高)
- 粗细:越重要越粗
- 位置:越重要越靠上、越靠左(阅读顺序)
- 空间:越重要周围留白越多
「民族图鉴」列表卡片的视觉权重分配:
- 民族名称(最高权重):最大、最粗、最左、最上
- 民族图标(次高权重):最大的视觉元素,在最左边
- 人口数量(中等权重):在名称下面,字小一点
- 地区标签(最低权重):在最右边,字最小、颜色最浅
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 图文混排卡片
图片和文字穿插排列,布局比较自由。
┌─────────────────────────┐
│ 标题 │
│ ┌──┐ 一段文字描述 │
│ │图│ ................ │
│ └──┘ ................ │
│ │
│ [按钮] [按钮] │
└─────────────────────────┘
特点:
- 内容丰富,形式多样
- 灵活,可以根据内容调整布局
- 设计难度高,容易乱
适用场景:
- 信息流、动态流
- 复杂内容的展示
- 社交类产品
💡 卡片选型的原则:
- 内容决定形式:有好看的图就用大图,没图就用纯文字,不要为了用卡片而硬加图
- 场景决定形式:浏览用大图,查找用小图,阅读用文字
- 保持统一:同一个列表里,卡片样式要统一,不要有的大图有的小图
卡片只是容器,内容才是主角。不要喧宾夺主。
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 长按菜单
长按卡片弹出操作菜单,也是一种常见的交互。
┌─────────────────────────┐
│ 卡片内容 │
└─────────────────────────┘
┌───────┐
│ 收藏 │
├───────┤
│ 分享 │
├───────┤
│ 删除 │
└───────┘
鸿蒙可以用 bindMenu 或 bindContextMenu 实现:
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
)
💡 交互设计的原则:
- 可发现:用户要知道"这里可以点/可以滑/可以长按"
- 即时反馈:操作了立刻有反应
- 可撤销:重要操作(比如删除)要能撤销
- 一致性:整个 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 毫秒,就会"掉帧"------用户感觉到卡顿。
卡片渲染慢的常见原因:
- 布局计算复杂:嵌套太深,每次布局都要算很久
- 绘制耗时:阴影、模糊、圆角、渐变...... 这些效果都要花时间绘制
- 图片加载:图片太大,解码和上传纹理都需要时间
- 频繁创建销毁:用 ForEach 的话,一开始创建所有卡片,内存占用大
- 状态更新频繁:短时间内多次更新状态,触发多次重渲染
6.6.2 优化手段清单
| 优化点 | 做法 | 效果 |
|---|---|---|
| 懒加载 | 用 LazyForEach 代替 ForEach | ⭐⭐⭐ 效果最大 |
| 简化结构 | 减少嵌套层级,尽量扁平 | ⭐⭐⭐ 效果明显 |
| 减少阴影 | 用背景色/边框代替阴影 | ⭐⭐ 效果不错 |
| 图片优化 | 尺寸合适、格式优化、懒加载 | ⭐⭐ 有图才需要 |
| 减少动画 | 非必要的动画就去掉 | ⭐ 看情况 |
| 复用组件 | 抽取公共组件,减少重复代码 | --- 代码层面 |
| 状态下放 | 状态尽量放在子组件,减少重渲染范围 | ⭐⭐ 架构层面 |
6.6.3 「民族图鉴」的性能表现
我们的卡片为什么流畅?因为:
- 结构简单:每张卡片只有 2-3 层嵌套
- 没有图片:用文字图标代替图片,省了图片加载的时间
- 没有阴影:用背景色区分,不用阴影
- LazyForEach:列表用懒加载,只创建可见的
56 张卡片,每张都很"轻",滚动起来当然流畅。
💡 性能优化的正确姿势:
- 先测量:用 DevEco Studio 的 Profiler 工具测一下,看看到底卡不卡,瓶颈在哪
- 再优化:针对瓶颈优化,不要凭感觉优化
- 再测量:优化完再测一遍,看有没有效果
不要上来就"我觉得会卡",然后做一堆优化。很多时候你担心的问题根本不是问题。
真正的性能问题,往往出在你想不到的地方。
📝 本章小结
核心知识点
本文深入讲解了列表项卡片的设计与实现,从设计原则到代码细节:
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 图片引用)
- 半透明遮罩层与文字可读性
- 沉浸式状态栏与滚动联动
- 返回按钮与分享按钮的设计
详情页是用户停留时间最长、信息密度最高的页面,也是最考验设计功力的页面。我们一步步来,从顶部开始。
🔗 相关链接
- 项目源码 : GitCode 仓库
- Text 组件 : 官方文档
- 触摸事件 : 官方文档
- 属性动画 : 官方文档
- 无障碍 : 官方文档
💡 提示:卡片设计看似简单,其实学问很大。同样是放一张图、两行字,为什么有的 App 看起来就高级,有的就很土?差别全在细节里------内边距多 2vp 少 2vp、字号大一号小一号、颜色深一点浅一点、圆角大一点小一点...... 这些细微的差别累积起来,就是"精致"和"粗糙"的差距。做卡片设计,要有"像素眼"------对着设计稿,一个像素一个像素地对齐。刚开始可能觉得麻烦,但时间长了,你会形成自己的审美和直觉,看到一张卡片就知道"哪里不对"。这就是设计师和普通开发者的区别。