
📖 引言
刷抖音的时候,你有没有注意到首页左上角那个"每日热榜"?
打开知乎,顶部是不是有个"每日新知"?
甚至连支付宝,都有个"每日一答"。
为什么各大 App 都喜欢做"每日 XX"?——因为它有魔力:
- 新鲜感:每天打开都有新内容,用户愿意每天来看一眼
- 轻量级:内容不多,一两句话,没有阅读压力
- 仪式感:“每天学一个小知识”,用户会有成就感
- 留存率:为了每天的冷知识,用户每天都打开 App,DAU 就上来了
「民族图鉴」也在首页放了一个"💡 每日一学"的卡片——每天展示一条有趣的民族冷知识,比如"傣族泼水节源于印度佛教浴佛仪式"、“纳西族东巴文是世界上唯一仍在使用的象形文字”。卡片右上角有个"换一个"按钮,点一下就换一条。
别看只是一个小卡片,里面的设计讲究可不少:
- 卡片怎么设计才有质感?圆角、阴影、边距怎么配?
- "每日一学"怎么保证每天内容不一样?
- 点击切换的交互怎么设计才自然?
- 卡片的点击反馈怎么做才舒服?
这一篇,我们就从这个小小的冷知识卡片开始,深入讲解卡片设计的方法论、每日内容的实现原理、以及点击交互的细节处理。小卡片,大学问。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握卡片设计的核心原则(圆角、阴影、间距)
- ✅ 学会实现"每日一换"的内容轮换机制
- ✅ 理解卡片点击反馈的设计(按压效果、涟漪效果)
- ✅ 掌握文本截断与行高的精细控制
- ✅ 学会用 Row + Blank 实现两端对齐布局
- ✅ 写出有质感、有交互、有细节的卡片组件
💡 需求分析
冷知识卡片的核心需求
| 需求点 | 说明 | 为什么重要 |
|---|---|---|
| 每日更新 | 每天显示不同的内容 | 给用户"每天来都有新东西"的预期 |
| 手动切换 | 点"换一个"可以换一条 | 用户想看更多,增加停留时间 |
| 卡片设计 | 圆角、阴影、边距,有质感 | 提升整体 UI 品质 |
| 文本适配 | 内容长短不一,要能优雅展示 | 短的不太空,长的不溢出 |
| 点击反馈 | 点击时有视觉反馈 | 让用户知道"点到了" |
| 跳转详情 | 点击卡片可以查看完整内容 | 引导用户深入使用 |
「民族图鉴」冷知识卡片设计
┌─────────────────────────────────────┐
│ 💡 每日一学 换一个 │ ← 标题栏:图标 + 标题 + 操作按钮
├─────────────────────────────────────┤
│ │
│ 傣族的泼水节其实源于印度佛教的浴佛 │ ← 内容区:3行文字,超出省略号
│ 仪式,后来演变为傣族最隆重的传统节 │
│ 日。... │
│ │
└─────────────────────────────────────┘
设计细节:
| 设计要素 | 值 | 说明 |
|---|---|---|
| 卡片圆角 | 16vp | 大圆角,现代感强 |
| 卡片背景 | 白色 | 和页面背景(浅灰)区分开 |
| 内边距 | 16vp | 内容不贴边,有呼吸感 |
| 标题字号 | 18fp | 加粗,醒目 |
| 正文字号 | 14fp | 适中,易读 |
| 正文行高 | 22fp | 行高约 1.57 倍,行距舒适 |
| 最大行数 | 3 行 | 超出显示省略号 |
| 标题栏和内容间距 | 8vp | 紧凑但不拥挤 |
卡片设计的"三层境界"
| 境界 | 描述 | 特点 |
|---|---|---|
| 第一层 | 能用 | 一个矩形,放点文字,能看 |
| 第二层 | 好看 | 圆角、阴影、配色都对,视觉舒适 |
| 第三层 | 有灵魂 | 交互细腻、动效自然、细节到位,用着舒服 |
我们的目标是——至少达到第二层,争取第三层。
🛠️ 核心实现
步骤1:卡片基础——一个有质感的卡片怎么画?
很多人觉得卡片不就是一个圆角矩形吗?有什么难的。
但你看看那些设计精良的 App,它们的卡片就是"看起来更舒服"。差别在哪?在细节。
1.1 卡片三要素:圆角、阴影、边距
一个有质感的卡片,至少要有这三样:
Column() {
// 卡片内容
}
.width('100%')
.padding(16) // 内边距:内容不贴边
.backgroundColor('#FFFFFF') // 背景:白色
.borderRadius(16) // 圆角:16vp,圆润
.shadow({ // 阴影:增加层次感
radius: 8,
color: '#1A000000',
offsetY: 2
})
为什么是这几个值?
- 圆角 16vp:太小了(4vp)显呆板,太大了(32vp)显卡通。12-16vp 是移动端卡片的黄金圆角,既现代又稳重。
- 内边距 16vp:8vp 太挤,24vp 太空。16vp 是最常用的卡片内边距,不挤不松刚刚好。
- 阴影:阴影不用大,淡淡的一层就行。半径 8、透明度 10%、Y 轴偏移 2——若有若无,增加层次感,又不会显得脏。
💡 阴影的使用原则:
- 宁淡勿浓:淡淡的阴影比浓浓的阴影好看
- 宁小勿大:小阴影比大阴影精致
- 偏移向下:Y 轴正偏移(向下),模拟自然光从上面照下来
- 透明度要低:10%-15% 就够了,太高了会显得脏
1.2 「民族图鉴」的冷知识卡片骨架
// pages/Index.ets
@Builder
buildDailyTriviaCard(): void {
Column({ space: $r('app.float.spacing_sm') }) {
// ---- 标题栏 ----
Row() {
Text('\u{1F4A1}') // 💡 灯泡 emoji
.fontSize($r('app.float.icon_size_md'))
Text($r('app.string.trivia_daily')) // "每日一学"
.fontSize($r('app.float.font_size_lg'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank() // 占位,把两边撑开
Text($r('app.string.home_trivia_new')) // "换一个"
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.primary_color'))
.onClick(() => {
this.switchTrivia(); // 切换下一条
})
}
.width('100%')
// ---- 内容区 ----
Text(this.getCurrentTriviaContent())
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_secondary'))
.lineHeight(22) // 行高,行距舒适
.maxLines(3) // 最多3行
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出省略号
.width('100%')
}
.width('90%')
.padding($r('app.float.spacing_lg')) // 卡片内边距
.backgroundColor($r('app.color.card_background'))
.borderRadius($r('app.float.radius_lg'))
.alignItems(HorizontalAlign.Start) // 内容左对齐
}
布局结构解析:
Column(卡片容器)
├── Row(标题栏)
│ ├── 💡 图标
│ ├── "每日一学" 标题文字
│ ├── Blank(占位,撑满空间)
│ └── "换一个" 按钮
└── Text(正文内容,3行省略)
1.3 两端对齐的秘密——Blank 组件
标题栏里有个 Blank(),这是什么东西?
Blank 是一个"空白占位符"——它会占据所有剩余空间,把左右两边的内容挤到两端去。
Row 宽度 = 300vp
┌─────────────────────────────────────────────┐
│ 💡 每日一学 [Blank] 换一个 │
└─────────────────────────────────────────────┘
↑ ↑ ↑ ↑
图标 标题 占满剩余 按钮
左边的图标和标题是"左对齐",右边的按钮是"右对齐",中间用 Blank 撑开。
这就是两端对齐的经典实现方式——简单、高效、好理解。
💡 Blank 的兄弟组件们:
Blank():占据所有剩余空间(用在 Row 里)Spacer():占据指定大小的空间(Spacer({ width: 10 }))- 两者的区别:Blank 是"能占多少占多少",Spacer 是"占指定大小"
步骤2:每日一学——怎么保证每天内容不一样?
“每日一学”,核心就是"每日"——今天打开是这条,明天打开是另一条,同一天打开多少次都是同一条。
怎么实现?
2.1 思路:用日期算索引
冷知识有一个数组,每天的日期不一样,算出来的索引就不一样。
// 冷知识数据
private triviaList: Array<TriviaItem> = [
{ id: 't1', category: 'fun_fact',
zhContent: '傣族的泼水节其实源于印度佛教的浴佛仪式...',
enContent: "The Dai Water Splashing Festival originated..." },
{ id: 't2', category: 'food',
zhContent: '蒙古族的传统美食"手把肉"是选用草原羊肉...',
enContent: "Mongolian 'hand-held meat' uses grassland lamb..." },
// ... 更多
];
@State dailyTriviaIndex: number = 0;
private loadDailyTrivia(): void {
const today = new Date();
// 计算今天是今年的第几天(0-364)
const startOfYear = new Date(today.getFullYear(), 0, 0);
const dayOfYear = Math.floor(
(today.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24)
);
// 用天数对数组长度取模,得到索引
this.dailyTriviaIndex = dayOfYear % this.triviaList.length;
this.currentTriviaContent = this.triviaList[this.dailyTriviaIndex];
}
原理:
- 一年有 365 天,冷知识有 N 条
- 第 1 天 → 索引 0 → 第 1 条
- 第 2 天 → 索引 1 → 第 2 条
- …
- 第 N 天 → 索引 N-1 → 第 N 条
- 第 N+1 天 → 索引 0 → 又回到第 1 条(循环)
同一天不管打开多少次,dayOfYear 都是一样的,所以显示的内容也一样。
💡 为什么不随机? 随机的话,用户每次打开都不一样,就没有"每日一学"的感觉了。"每日一学"的仪式感就在于——今天就是这条,明天再来就有新的。你每天打开 App,就像拆礼物一样,期待今天的冷知识是什么。
2.2 手动切换——"换一个"按钮
右上角有个"换一个"按钮,点一下就换一条。
// 当前内容
@State currentTriviaContent: TriviaItem = ...;
/**
* 切换到下一条冷知识
*/
private switchTrivia(): void {
this.dailyTriviaIndex = (this.dailyTriviaIndex + 1) % this.triviaList.length;
this.currentTriviaContent = this.triviaList[this.dailyTriviaIndex];
}
很简单,索引 +1,对长度取模,到末尾了就回到开头,循环切换。
2.3 切换时加个小动画
直接切换太生硬了,加个淡入淡出动画,体验好很多:
@State contentOpacity: number = 1;
private switchTrivia(): void {
// 第一步:淡出
animateTo({
duration: 150,
curve: Curve.EaseIn
}, () => {
this.contentOpacity = 0;
});
// 第二步:等淡出完成,换内容,再淡入
setTimeout(() => {
this.dailyTriviaIndex = (this.dailyTriviaIndex + 1) % this.triviaList.length;
this.currentTriviaContent = this.triviaList[this.dailyTriviaIndex];
animateTo({
duration: 200,
curve: Curve.EaseOut
}, () => {
this.contentOpacity = 1;
});
}, 150);
}
// 给 Text 加透明度
Text(this.currentTriviaContent.zhContent)
.opacity(this.contentOpacity)
// ... 其他属性
150ms 淡出 → 换内容 → 200ms 淡入。整个过程 350ms,不快不慢,用户能感觉到"换了",但又不会等太久。
💡 动画时长的经验值:
- 快速反馈:100-150ms(按钮按压、小元素变化)
- 正常过渡:200-300ms(页面切换、内容替换)
- 慢动作展示:500-800ms(启动动画、强调效果)
- 超过 1 秒的动画要谨慎——用户可能等不耐烦
步骤3:文本排版——让文字看着舒服
卡片里最重要的就是文字了。文字排得好不好,直接决定卡片的质感。
3.1 字号与字重
| 层级 | 字号 | 字重 | 颜色 | 用途 |
|---|---|---|---|---|
| 标题 | 18fp | Bold | 主文字色(#1A1A1A) | 卡片标题、重要信息 |
| 正文 | 14fp | Regular | 次要文字色(#666666) | 正文内容、描述 |
| 辅助 | 12fp | Regular | 提示文字色(#999999) | 时间、小字说明 |
三级字号、三级颜色——这就是卡片排版的"3-3 法则"。不要搞出五六个字号、五六个颜色,那样会乱。
3.2 行高(lineHeight)
行高就是每行文字的高度。行高 = 字号 + 上下行距。
行高太小,文字挤在一起,看着累;行高太大,文字太散,读着断。
经验值:
- 正文:行高 = 字号 × 1.5 ~ 1.6
- 标题:行高 = 字号 × 1.2 ~ 1.3
比如 14fp 的正文,行高 22fp:
22 ÷ 14 ≈ 1.57——完美比例,看着最舒服。
Text('正文内容...')
.fontSize(14)
.lineHeight(22) // 行高约 1.57 倍
3.3 行数限制与省略
卡片的空间有限,内容太长怎么办?——限制行数,超出显示省略号。
Text('很长很长的内容...')
.maxLines(3) // 最多3行
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出显示省略号
.width('100%') // 一定要有宽度限制
注意:maxLines 和 textOverflow 要配合 width 使用——没有宽度限制,Text 会无限宽,永远不会换行,自然也不会有省略号。
3.4 文字对齐
- 标题:左对齐(自然,符合阅读习惯)
- 正文:左对齐(中文阅读习惯是左对齐)
- 数字/价格:右对齐(方便对比大小)
卡片内的文字基本都是左对齐的,不要居中——居中适合大标题,不适合正文阅读。
💡 为什么中文正文推荐左对齐? 因为中文是方块字,左对齐的话,左边是齐的,眼睛沿着左边往下扫就行,阅读效率高。居中对齐的话,每行开头位置不一样,眼睛要不断找开头,累。居中适合短标题、装饰性文字,不适合大段正文。
步骤4:点击反馈——让卡片"可点击"的感觉
一个可点击的卡片,用户点下去的时候,要有视觉反馈——让用户知道"我点到了,它响应了"。
最常见的反馈方式有三种:
| 方式 | 效果 | 适用场景 |
|---|---|---|
| 透明度变化 | 点击时透明度变低(0.7 左右) | 最常用,实现简单 |
| 缩放效果 | 点击时缩小一点(0.95 倍) | 比较有质感,按钮常用 |
| 涟漪效果 | 点击位置扩散出水波纹 | Material Design 风格 |
4.1 透明度反馈(最简单)
@State isPressed: boolean = false;
Column() {
// 卡片内容
}
.opacity(this.isPressed ? 0.7 : 1) // 按下时 70% 透明度
.gesture(
TapGesture()
.onAction(() => {
// 点击动作
this.onCardClick();
})
)
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.isPressed = true; // 按下
} else if (event.type === TouchType.Up ||
event.type === TouchType.Cancel) {
this.isPressed = false; // 抬起 / 取消
}
})
用 onTouch 事件监听按下和抬起,改变透明度。
4.2 缩放反馈(更有质感)
@State cardScale: number = 1;
Column() {
// 卡片内容
}
.scale({ x: this.cardScale, y: this.cardScale })
.animation({ duration: 100, curve: Curve.EaseInOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.cardScale = 0.97; // 按下,缩小到 97%
} else if (event.type === TouchType.Up ||
event.type === TouchType.Cancel) {
this.cardScale = 1; // 抬起,恢复
}
})
缩小 3%(0.97 倍)就够了,缩小太多(0.9 倍)会显得夸张。配合 100ms 的动画,轻轻弹一下,手感很好。
💡 按压效果的注意事项:
- 变化要小:透明度 0.7、缩放 0.97,意思到了就行,别太夸张
- 动画要快:100-150ms,跟手,不能慢半拍
- 松开要恢复:手指抬起立即恢复,不能一直保持按压状态
- 移动取消:手指按下去后移出去再松开,不触发点击(这是标准交互)
步骤5:卡片组件封装——可复用的卡片
如果应用里有很多类似的卡片,每次都写一遍 Column、borderRadius、shadow… 太麻烦了。
把卡片封装成一个通用组件,用的时候直接传内容进去,又快又统一。
5.1 通用卡片组件
// components/common/CommonCard.ets
/**
* 文件用途:通用卡片组件
* 创建时间:2026-06-22
* 兼容环境:macOS/Linux/Docker/TRAE 云端
* 版本:v1.0
* 风险提示:无
*/
interface CardPadding {
left?: number;
right?: number;
top?: number;
bottom?: number;
}
@Component
export struct CommonCard {
@Prop padding: number | CardPadding = 16;
@Prop radius: number = 16;
@Prop backgroundColor: ResourceColor = '#FFFFFF';
@Prop hasShadow: boolean = false;
@Prop clickable: boolean = false;
@State isPressed: boolean = false;
@BuilderParam contentBuilder: () => void; // 内容构建器
onCardClick?: () => void; // 点击回调
build() {
Column() {
this.contentBuilder()
}
.width('100%')
.padding(this.padding)
.backgroundColor(this.backgroundColor)
.borderRadius(this.radius)
.opacity(
this.clickable && this.isPressed ? 0.85 : 1
)
.shadow(this.hasShadow ? {
radius: 8,
color: '#1A000000',
offsetY: 2
} : undefined)
.onTouch((event: TouchEvent) => {
if (!this.clickable) return;
if (event.type === TouchType.Down) {
this.isPressed = true;
} else if (event.type === TouchType.Up ||
event.type === TouchType.Cancel) {
this.isPressed = false;
}
})
.onClick(() => {
if (this.clickable && this.onCardClick) {
this.onCardClick();
}
})
}
}
5.2 使用方式
// 用通用卡片组件
CommonCard({
padding: 16,
radius: 16,
hasShadow: true,
clickable: true,
onCardClick: () => {
console.info('卡片被点击了');
},
contentBuilder: () => {
Column() {
Text('卡片标题')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('卡片内容...')
.fontSize(14)
.fontColor('#666')
.margin({ top: 8 })
}
.width('100%')
}
})
用 @BuilderParam 把内容传进去,卡片的样式、交互都封装在组件里了。用的时候只关心内容,不用每次都写圆角、阴影、点击效果。
这就是组件化的好处——一次封装,处处使用;样式统一,维护方便。要改卡片的圆角,只改一个地方,所有卡片都更新了。
⚠️ 常见问题与解决方案
问题1:卡片文字太少,显得太空;文字太多,又撑得太大
现象:
卡片内容长短不一,短的内容卡片太小气,长的内容卡片太大,高度不统一,看起来不整齐。
解决方案:
方案1:固定卡片高度(推荐)
给卡片一个固定的最小高度,内容少也有这么高,内容多了可以往下撑(或者限制行数)。
Column() {
// 内容
}
.width('100%')
.minHeight(120) // 最小高度 120vp,内容少也这么高
.padding(16)
方案2:限制行数 + 固定行高
Text('内容...')
.fontSize(14)
.lineHeight(22) // 每行 22vp
.maxLines(3) // 最多3行,3×22=66vp 的内容高度
.textOverflow({ overflow: TextOverflow.Ellipsis })
这样不管内容多少,卡片高度都差不多,看起来整齐。
方案3:全部展示,高度自适应
如果是列表里的卡片,每个高度不一样也没关系——瀑布流的感觉也挺好。但要注意行间距统一,不然会乱。
💡 「民族图鉴」的选择:冷知识卡片用的是方案2——3行限制 + 行高 22vp。因为卡片在首页,首页内容多,卡片太高了会把其他内容挤下去。3 行刚刚好,既能展示足够的信息,又不会占用太多空间。想看完完整内容?点进去看详情页嘛。
问题2:卡片阴影在深色模式下不好看
现象:
浅色模式下,白色卡片配浅灰阴影,很好看。深色模式下,深灰卡片配黑色阴影,几乎看不见,或者显得很脏。
解决方案:
方案1:深色模式去掉阴影,用边框区分
// 浅色模式:阴影
// 深色模式:不用阴影,用亮一点的边框区分
.border({
width: 1,
color: this.isDarkMode ? '#333333' : 'transparent'
})
.shadow(this.isDarkMode ? undefined : {
radius: 8,
color: '#1A000000',
offsetY: 2
})
深色模式下,背景已经很黑了,阴影看不出来。用一个稍微亮一点的边框来区分卡片和背景,更清晰。
方案2:用颜色资源,自动适配
把阴影颜色、边框颜色都定义成颜色资源,深色模式下自动用另一套值:
// resources/base/element/color.json
{
"color": [
{ "name": "card_shadow", "value": "#1A000000" },
{ "name": "card_border", "value": "#00FFFFFF" }
]
}
// resources/dark/element/color.json
{
"color": [
{ "name": "card_shadow", "value": "#00000000" }, // 透明,没有阴影
{ "name": "card_border", "value": "#33FFFFFF" } // 白色半透明边框
]
}
代码里直接用资源引用,系统自动匹配。
问题3:卡片点击区域太小,点不到
现象:
卡片里有个"换一个"的文字按钮,字很小,很难点中。
原因:
文字按钮的点击区域就是文字的大小,可能只有 20×14vp,远远小于 44vp 的最小触控目标。
解决方案:
扩大点击区域——padding 大法
// ❌ 只有文字大小,点不到
Text('换一个')
.fontSize(12)
.onClick(() => { /* ... */ })
// ✅ 加 padding,点击区域变大
Text('换一个')
.fontSize(12)
.padding({ left: 12, right: 12, top: 8, bottom: 8 }) // 点击区域变大
.onClick(() => { /* ... */ })
padding 不会改变文字的显示位置,但会扩大组件的点击区域。加了 padding 之后,点击区域可能就有 60×30vp 了,好点多了。
移动端有个"44dp 法则"——所有可点击的元素,触控目标至少要 44×44dp(vp)。太小了手指点不准,用户会抓狂。文字按钮、图标按钮尤其要注意——看起来小小的,但点击区域要够大。
问题4:卡片内的图片加载慢,有空白闪烁
现象:
卡片里有图片,第一次打开的时候图片还在加载,卡片是空白的,等图片加载出来"啪"一下出现,很突兀。
解决方案:
方案1:占位图 + 淡入动画
@State imageLoaded: boolean = false;
Stack() {
// 占位图:灰色圆角矩形
Rect()
.width('100%')
.height(120)
.fill('#EEEEEE')
.borderRadius(8)
// 真实图片
Image(this.coverImage)
.width('100%')
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius(8)
.opacity(this.imageLoaded ? 1 : 0)
.animation({ duration: 300, curve: Curve.EaseInOut })
.onComplete(() => {
this.imageLoaded = true; // 图片加载完,淡入
})
}
图片加载前显示灰色占位,加载完淡入出现,不突兀。
方案2:用默认图片
Image(this.coverImage || $r('app.media.default_cover'))
.width('100%')
.height(120)
如果图片加载失败,显示默认图,不会出现空白或者裂图。
问题5:卡片列表滚动时卡顿
现象:
页面里有很多卡片(比如几十上百个),滚动的时候掉帧、卡顿。
原因分析:
| 可能原因 | 说明 |
|---|---|
| 卡片太复杂 | 每个卡片里有很多组件、很多嵌套 |
| 阴影/模糊太重 | 阴影、模糊效果很耗性能,太多了会卡 |
| 用了 ForEach | 全量渲染,一次创建所有卡片 |
| 图片太大 | 每张图都很大,内存爆了 |
解决方案:
1. 用 List + LazyForEach 懒加载
// ❌ ForEach 一次创建所有
Scroll() {
Column() {
ForEach(this.bigList, (item) => {
MyCard({ item: item })
})
}
}
// ✅ List + LazyForEach,只渲染可见的
List() {
LazyForEach(this.dataSource, (item) => {
ListItem() {
MyCard({ item: item })
}
}, item => item.id)
}
2. 简化卡片结构
// ❌ 嵌套太深
Column() {
Row() {
Column() {
Stack() {
// ...
}
}
}
}
// ✅ 尽量扁平,减少层级
Row() {
Image(...)
Column() {
Text(...)
Text(...)
}
.layoutWeight(1)
}
3. 阴影不要太多太重
淡淡的阴影就好,不要大阴影、多层阴影。性能敏感的场景,甚至可以不用阴影,用边框代替。
💡 性能优化的黄金法则:先看是不是真的卡——在真机上跑一下,60fps 就是流畅的。不要凭空优化,也不要过度优化。大多数情况下,十几二十个卡片,怎么写都不会卡。真卡的时候,再用 DevEco Profiler 分析瓶颈在哪。
🧠 进阶拓展:冷知识卡片的深度设计
6.1 冷知识卡片的设计心理学
为什么"每日一学"这种小卡片能提升用户留存?背后有扎实的心理学原理。
6.1.1 好奇心驱动:信息缺口理论
诺贝尔经济学奖得主丹尼尔·卡尼曼提出过一个理论:当人们觉得自己的知识有"缺口"时,就会产生强烈的好奇心,想要填补这个缺口。
冷知识就是利用了这个心理:
- “你知道吗?傣族泼水节其实源于……” → 话说一半,你想不想知道后半句?
- “纳西族有一种文字,是世界上唯一仍在使用的象形文字” → 哦?是什么文字?我怎么不知道?
这种"差一点就知道了"的感觉,会促使用户点进去看详情。
「民族图鉴」的设计实践:
冷知识标题:💡 每日一学
内容前半段:傣族的泼水节其实源于印度佛教的浴佛仪式...
(话说一半,用省略号结尾)
操作提示:点击查看详情 →
前半段勾起好奇心,"点击查看详情"满足好奇心。一勾一放,用户就点进去了。
💡 好奇心设计的三个层次:
- 第一层:标题党(震惊!XX竟然是这样的)—— 低质,用完即走
- 第二层:信息缺口(你知道吗?XX其实是……)—— 中质,有效但需把握度
- 第三层:价值+好奇(这个知识很有用,而且你不知道)—— 高质,用户既学了东西又有获得感
6.1.2 碎片化学习:一分钟也能有收获
现代人注意力越来越短,但又有"自我提升"的焦虑。冷知识卡片完美契合了这种需求:
- 时间短:30秒就能看完一条,没有压力
- 获得感强:“我今天又学了一个新知识”,满足感强
- 仪式感:每天一条,像完成任务一样,有打卡的感觉
这种"微学习"的模式,用户接受度特别高。
6.1.3 可变奖励:斯金纳箱原理
你点"换一个"按钮的时候,会不会有点小期待?—— 下一条会是什么有趣的知识?
这就是可变奖励:你不知道下一条是什么,但每次都可能有惊喜。就像老虎机一样,让人想一直按下去。
「民族图鉴」的"换一个"按钮,就是利用了这个心理——用户按一下,换一条,再按一下,又换一条…… 不知不觉就在首页停留了好几分钟。
当然,这个度要把握好。不能让用户沉迷,也不能内容质量太差。我们的初衷是传播民族文化知识,让用户在轻松愉快的氛围中学到东西。
6.2 卡片翻转动画的实现
刚才我们实现的是"淡入淡出"的切换效果。但如果你想做的更酷炫一点——比如卡片翻转呢?就像翻书一样,点一下,卡片翻个面,背面是新的内容。
6.2.1 3D翻转的原理
卡片翻转的本质是:两张卡片,一张正面、一张背面,围绕Y轴旋转,正面转到看不见,背面转到看得见。
正面可见(0度) 翻转中(90度) 背面可见(180度)
┌──────────┐ ┌─┐ ┌──────────┐
│ 正面内容 │ → │ │ → │ 背面内容 │
└──────────┘ └─┘ └──────────┘
关键技巧:
- 用
rotateY做Y轴旋转 - 用
perspective增加透视感(近大远小) - 旋转到90度的时候切换内容(这时候看不见,切换无感知)
6.2.2 代码实现
// components/FlipCard.ets
@Component
export struct FlipCard {
@State frontContent: string = '正面内容';
@State backContent: string = '背面内容';
@State isFlipped: boolean = false;
@State rotationY: number = 0;
private flipCard(): void {
if (this.isFlipped) {
// 从背面翻回正面
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.rotationY = 0;
});
} else {
// 从正面翻到背面
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.rotationY = 180;
});
}
this.isFlipped = !this.isFlipped;
}
build() {
Stack() {
// 正面
Text(this.frontContent)
.fontSize(16)
.width('100%')
.height('100%')
.textAlign(TextAlign.Center)
.rotate({
y: 1,
angle: this.rotationY // 正面跟着一起转
})
.opacity(this.isFlipped ? 0 : 1) // 转到背面时隐藏正面
// 背面
Text(this.backContent)
.fontSize(16)
.width('100%')
.height('100%')
.textAlign(TextAlign.Center)
.rotate({
y: 1,
angle: this.rotationY + 180 // 背面一开始就是反的,跟着一起转
})
.opacity(this.isFlipped ? 1 : 0) // 转到正面时显示背面
}
.width(200)
.height(120)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.rotate({ y: 1, angle: this.rotationY })
.perspective(1000) // 透视效果,值越小透视感越强
.onClick(() => {
this.flipCard();
})
}
}
6.2.3 「民族图鉴」的翻牌冷知识
我们可以把冷知识卡片做成"问答翻转"的形式:
- 正面:一个问题(“傣族泼水节源于什么?”)
- 背面:答案 + 详细解释
用户先看问题,猜一猜,然后点一下翻牌看答案。这种互动感更强,记忆也更深刻。
// 问答式冷知识卡片
@State question: string = '傣族泼水节源于什么?';
@State answer: string = '源于印度佛教的浴佛仪式,后来演变为傣族最隆重的传统节日。';
@State isFlipped: boolean = false;
Stack() {
// 正面:问题
Column() {
Text('🤔 冷知识问答')
.fontSize(14)
.fontColor('#666')
Text(this.question)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ top: 12 })
Text('点击翻牌看答案')
.fontSize(12)
.fontColor('#999')
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.opacity(this.isFlipped ? 0 : 1)
// 背面:答案
Column() {
Text('💡 答案')
.fontSize(14)
.fontColor('#666')
Text(this.answer)
.fontSize(16)
.margin({ top: 12 })
Text('你答对了吗?')
.fontSize(12)
.fontColor('#999')
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.rotate({ y: 1, angle: 180 })
.opacity(this.isFlipped ? 1 : 0)
}
.width('100%')
.height(160)
.rotate({ y: 1, angle: this.isFlipped ? 180 : 0 })
.perspective(1000)
.animation({ duration: 400, curve: Curve.EaseInOut })
💡 翻转动画的使用场景:
- 问答卡片(问题→答案)
- 单词卡片(单词→释义)
- 人物介绍(照片→简介)
- 商品卡片(正面图→背面图)
但要注意:不要为了炫技而炫技。如果内容不适合翻转,就用普通的切换方式。动画是为内容服务的,不是反过来。
6.3 每日更新机制的深度解析
"每日一学"的核心是——同一天打开,内容一样;第二天打开,内容变了。
刚才我们实现了简单版:用日期对数组长度取模。但真实项目中,这个机制会更复杂。
6.3.1 三种每日更新方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 本地日期取模 | 日期 % 内容数量 = 今日索引 | 实现简单,完全离线 | 内容循环,周期固定 | 内容少、纯离线 |
| 本地缓存+日期标记 | 本地存"最后更新日期"和"今日内容",日期变了就更新 | 灵活,可以随机 | 还是只能用本地内容 | 内容较多、离线使用 |
| 服务端下发 | 每天请求接口,服务端返回今日内容 | 内容可以随时更新,个性化推荐 | 需要网络,依赖服务端 | 有后端、内容经常更新 |
6.3.2 方案二:本地缓存+日期标记(推荐)
这个方案比"日期取模"好一点——每天的内容是随机的,但同一天打开内容不变。
// utils/DailyTriviaManager.ets
import preferences from '@ohos.data.preferences';
const TRIVIA_LIST = [
{ id: '1', content: '傣族泼水节源于印度佛教浴佛仪式...' },
{ id: '2', content: '纳西族东巴文是世界上唯一仍在使用的象形文字...' },
// ... 更多
];
export class DailyTriviaManager {
private static PREFS_NAME = 'daily_trivia_prefs';
private static KEY_LAST_DATE = 'last_date';
private static KEY_CURRENT_ID = 'current_id';
// 获取今日冷知识
static async getDailyTrivia(): Promise<{ id: string; content: string }> {
const prefs = await preferences.getPreferences(getContext(), this.PREFS_NAME);
const today = this.getTodayString();
const lastDate = await prefs.get(this.KEY_LAST_DATE, '') as string;
// 如果今天已经获取过,直接返回存好的
if (lastDate === today) {
const currentId = await prefs.get(this.KEY_CURRENT_ID, '') as string;
const trivia = TRIVIA_LIST.find(t => t.id === currentId);
if (trivia) return trivia;
}
// 今天还没获取过,随机选一条新的
const randomIndex = Math.floor(Math.random() * TRIVIA_LIST.length);
const trivia = TRIVIA_LIST[randomIndex];
// 存起来
await prefs.put(this.KEY_LAST_DATE, today);
await prefs.put(this.KEY_CURRENT_ID, trivia.id);
await prefs.flush();
return trivia;
}
// 换一条
static async getNextTrivia(currentId: string): Promise<{ id: string; content: string }> {
const currentIndex = TRIVIA_LIST.findIndex(t => t.id === currentId);
const nextIndex = (currentIndex + 1) % TRIVIA_LIST.length;
return TRIVIA_LIST[nextIndex];
}
private static getTodayString(): string {
const date = new Date();
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
}
这个方案的好处:
- 每天打开都是新内容,同一天打开内容不变
- 内容是随机的,用户猜不到明天是什么,有惊喜感
- 完全离线,不需要网络
- 用 preferences 持久化,App 重启也没关系
6.3.3 方案三:服务端下发(进阶)
如果你的 App 有后端,那最好的方式是服务端下发今日内容:
// api/triviaApi.ets
export interface DailyTrivia {
id: string;
title: string;
content: string;
imageUrl?: string;
source?: string;
date: string;
}
export async function fetchDailyTrivia(): Promise<DailyTrivia> {
// 1. 先看本地有没有今天的缓存
const prefs = await preferences.getPreferences(getContext(), 'trivia_cache');
const today = getTodayString();
const cachedDate = await prefs.get('cached_date', '') as string;
if (cachedDate === today) {
const cachedData = await prefs.get('cached_data', '') as string;
if (cachedData) {
return JSON.parse(cachedData);
}
}
// 2. 本地没有,请求服务端
try {
const response = await fetch('https://api.example.com/trivia/daily');
const data = await response.json() as DailyTrivia;
// 3. 存到本地缓存
await prefs.put('cached_date', today);
await prefs.put('cached_data', JSON.stringify(data));
await prefs.flush();
return data;
} catch (error) {
// 4. 请求失败,返回本地兜底内容
console.error('获取每日冷知识失败', error);
return getFallbackTrivia();
}
}
服务端方案的优势:
- 内容运营灵活:运营可以在后台随时更新今日内容,不用发版
- 个性化推荐:根据用户的浏览历史、喜好推荐不同的冷知识
- 数据分析:知道哪些冷知识点击率高,优化内容策略
- A/B测试:给不同用户看不同内容,测试哪种效果好
「民族图鉴」目前内容还不多,先用方案二(本地缓存+日期标记)就够了。等以后内容多了、有后端了,再切到方案三。技术选型要匹配业务阶段,不要一开始就搞最复杂的。
6.4 分享功能的设计
看到有趣的冷知识,用户会不会想分享给朋友?—— 会的!所以分享功能一定要有。
6.4.1 分享的价值
分享功能不只是方便用户,对产品也有很大价值:
- 病毒传播:用户分享给朋友,朋友下载 App,用户增长了
- 品牌曝光:分享出去的内容带着你的 Logo 和 App 名称
- 用户粘性:能分享的内容,用户会更认真看,记忆更深
6.4.2 分享的几种形式
| 分享形式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 纯文字 | 分享一段文字 | 实现简单 | 不够吸引人 |
| 图片海报 | 生成一张精美的图片分享 | 视觉效果好,传播性强 | 实现复杂 |
| 链接+卡片 | 分享一个链接,对方点开是H5页面 | 可跳转下载,转化高 | 需要后端H5页面 |
| 系统分享 | 调用系统分享面板,用户选分享到哪 | 用户选择多,体验好 | 分享效果不可控 |
6.4.3 「民族图鉴」的分享实现
我们做一个图片海报分享——把冷知识生成一张精美的卡片图片,用户可以保存到相册或分享到微信。
// 分享按钮
Row() {
Text('分享')
.fontSize(12)
.fontColor('#666')
Image($r('app.media.ic_share'))
.width(14)
.height(14)
.margin({ left: 4 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#F5F5F5')
.borderRadius(16)
.onClick(() => {
this.showShareSheet();
})
// 分享面板
@State showShare: boolean = false;
private showShareSheet(): void {
this.showShare = true;
}
// 分享选项
@Builder
buildShareSheet() {
if (this.showShare) {
Stack() {
// 遮罩层
Rect()
.width('100%')
.height('100%')
.fill('#000000')
.opacity(0.5)
.onClick(() => {
this.showShare = false;
})
// 分享面板
Column({ space: 16 }) {
Text('分享到')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Row({ space: 24 }) {
this.buildShareItem('微信', $r('app.media.ic_wechat'))
this.buildShareItem('朋友圈', $r('app.media.ic_moments'))
this.buildShareItem('微博', $r('app.media.ic_weibo'))
this.buildShareItem('保存图片', $r('app.media.ic_save'))
}
Button('取消')
.width('100%')
.height(44)
.backgroundColor('#F5F5F5')
.fontColor('#333')
.onClick(() => {
this.showShare = false;
})
}
.width('100%')
.padding(24)
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 24, topRight: 24 })
.position({ y: '100%' })
.translate({ y: this.showShare ? -300 : 0 })
.animation({ duration: 300, curve: Curve.EaseOut })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
}
@Builder
buildShareItem(name: string, icon: Resource) {
Column({ space: 8 }) {
Image(icon)
.width(44)
.height(44)
Text(name)
.fontSize(12)
.fontColor('#666')
}
.onClick(() => {
this.handleShare(name);
this.showShare = false;
})
}
private handleShare(platform: string): void {
// 生成分享图片
// 调用系统分享或对应平台SDK
console.info(`分享到 ${platform}`);
}
💡 分享文案的设计技巧:
- 要有吸引力,让人想点开
- 要带关键词,方便传播
- 不要太长,手机一屏能看完
「民族图鉴」分享文案示例:
💡 冷知识:傣族泼水节竟然源于印度佛教的浴佛仪式!
更多有趣的民族知识,快来下载「民族图鉴」看看吧~
6.5 收藏冷知识
看到喜欢的冷知识,用户想收藏起来以后慢慢看——这个需求也很常见。
6.5.1 收藏功能的实现思路
- 每条冷知识有一个唯一 ID
- 用本地存储(Preferences)保存收藏列表
- 进入详情页时判断是否已收藏,显示对应的图标
- 点击收藏按钮,添加/移除收藏
6.5.2 代码实现
// utils/FavoriteManager.ets
import preferences from '@ohos.data.preferences';
export class FavoriteManager {
private static PREFS_NAME = 'favorites_prefs';
private static KEY_FAVORITES = 'favorite_ids';
// 获取所有收藏ID
static async getFavorites(): Promise<string[]> {
const prefs = await preferences.getPreferences(getContext(), this.PREFS_NAME);
const favoritesStr = await prefs.get(this.KEY_FAVORITES, '[]') as string;
try {
return JSON.parse(favoritesStr) as string[];
} catch {
return [];
}
}
// 是否已收藏
static async isFavorite(id: string): Promise<boolean> {
const favorites = await this.getFavorites();
return favorites.includes(id);
}
// 添加收藏
static async addFavorite(id: string): Promise<void> {
const favorites = await this.getFavorites();
if (!favorites.includes(id)) {
favorites.push(id);
const prefs = await preferences.getPreferences(getContext(), this.PREFS_NAME);
await prefs.put(this.KEY_FAVORITES, JSON.stringify(favorites));
await prefs.flush();
}
}
// 取消收藏
static async removeFavorite(id: string): Promise<void> {
const favorites = await this.getFavorites();
const index = favorites.indexOf(id);
if (index > -1) {
favorites.splice(index, 1);
const prefs = await preferences.getPreferences(getContext(), this.PREFS_NAME);
await prefs.put(this.KEY_FAVORITES, JSON.stringify(favorites));
await prefs.flush();
}
}
// 切换收藏状态
static async toggleFavorite(id: string): Promise<boolean> {
const isFav = await this.isFavorite(id);
if (isFav) {
await this.removeFavorite(id);
return false;
} else {
await this.addFavorite(id);
return true;
}
}
}
在卡片上使用:
@State isFavorited: boolean = false;
// 收藏按钮
Image(this.isFavorited ? $r('app.media.ic_star_filled') : $r('app.media.ic_star'))
.width(20)
.height(20)
.fillColor(this.isFavorited ? '#FFD700' : '#999999')
.onClick(async () => {
this.isFavorited = await FavoriteManager.toggleFavorite(this.trivia.id);
})
收藏功能虽然简单,但它是提升用户留存的重要手段。用户收藏的内容越多,离开 App 的成本就越高(“我收藏了那么多东西,舍不得删”)。这就是所谓的"沉没成本"。
📝 本章小结
核心知识点
本文通过冷知识卡片这个小案例,深入讲解了卡片设计与交互的方方面面:
1. 卡片设计三要素
- 圆角:12-16vp,现代感
- 边距:16vp 内边距,呼吸感
- 阴影:淡淡的一层,增加层次感
- 原则:宁淡勿浓,宁小勿大
2. 两端对齐布局
- 用
Blank()撑开左右,实现两端对齐 - 图标 + 标题在左,操作按钮在右
- 这是最经典的标题栏布局方式
3. 每日一学实现
- 用"一年中的第几天"对数组长度取模,得到每日索引
- 同一天打开多少次内容都一样,有仪式感
- 手动切换:索引 +1 取模,循环切换
- 切换动画:淡出 → 换内容 → 淡入
4. 文本排版
- 三级字号:标题(18)、正文(14)、辅助(12)
- 三级颜色:主文字(#1A1A1A)、次文字(#666)、提示(#999)
- 行高:正文 1.5-1.6 倍,标题 1.2-1.3 倍
- 行数限制:maxLines + textOverflow,超出省略号
5. 点击反馈
- 透明度变化:最简单,按下变 70% 透明
- 缩放效果:更有质感,按下缩到 97%
- 涟漪效果:Material 风格
- 原则:变化要小,动画要快,跟手
6. 卡片组件封装
- 把通用样式和交互封装成 CommonCard 组件
- 用 @BuilderParam 传入内容
- 好处:样式统一、维护方便、开发效率高
最佳实践总结
✅ 卡片三要素:圆角 16 + 边距 16 + 淡阴影
Column() { /* ... */ }
.padding(16)
.borderRadius(16)
.shadow({ radius: 8, color: '#1A000000', offsetY: 2 })
✅ 标题栏两端对齐:图标 + 标题 + Blank + 按钮
Row() {
Text('💡')
Text('每日一学').fontWeight(FontWeight.Bold)
Blank()
Text('换一个').fontColor(Color.Red)
}
.width('100%')
✅ 文本三级体系:三级字号 + 三级颜色
标题:18fp + Bold + 主文字色
正文:14fp + Regular + 次文字色
辅助:12fp + Regular + 提示文字色
✅ 行高 1.57 倍最舒服
.fontSize(14)
.lineHeight(22) // 22/14 ≈ 1.57
✅ 点击反馈:小变化,快动画
.opacity(isPressed ? 0.85 : 1)
.animation({ duration: 100 })
✅ 可点击元素最小 44vp
// 小按钮也要加 padding 扩大点击区域
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
下一篇预告
冷知识卡片搞定了,首页还有个重要的模块——精选民族横向滚动卡片。
下一篇(第15篇)我们将讲解首页精选民族——横向滚动卡片列表:
- Scroll 横向滚动的实现
- 卡片列表的性能优化
- 卡片点击跳转详情页
- 滚动指示器的实现
横向滚动列表是首页最常用的展示形式之一,也是最能体现"逛"的感觉的交互方式。
🔗 相关链接
- 项目源码: GitCode 仓库
- 首页源码: [Index.ets](file:///e:/HuaweiDevEco/Project/project003/entry/src/main/ets/pages/Index.ets)
- Text 组件: 官方文档
- Blank 组件: 官方文档
- 属性动画: 官方文档
- 触摸事件: 官方文档
💡 提示:卡片设计是 UI 开发里最基础也最能体现功底的东西。说它基础,是因为到处都是卡片,每个人都会写;说它看功底,是因为同样是卡片,有人写出来就很精致,有人写出来就很粗糙。差别在哪?不在功能,在细节——圆角 12 和 16 的差别、阴影半径 6 和 8 的差别、行高 20 和 22 的差别、按下透明度 0.8 和 0.85 的差别…… 这些细节单独看都不明显,但堆在一起,质感的差距就出来了。做 UI 开发,要培养对细节的敏感度——多看看好的设计,多琢磨为什么好看,慢慢你的审美和手感就上来了。
299

被折叠的 条评论
为什么被折叠?



