HarmonyOS应用<奇妙科学乐园>开发第44篇:文章卡片组件TopicCard——图片+标题+分类封装

HarmonyOS应用<奇妙科学乐园>开发第44篇:文章卡片组件TopicCard——图片+标题+分类封装

📖 引言

在上一篇文章中,我们详细实现了首页的 Banner 轮播图组件BannerCarousel,通过 Swiper 容器、自动轮播、自定义指示器圆点等技术,打造了一个视觉吸引力极强的首屏展示区域。当用户滑动浏览完轮播图后,接下来映入眼帘的就是科普文章列表——每一篇科普文章都由一个精心设计的卡片呈现,这就是本文的主角:TopicCard 文章卡片组件

卡片组件是移动端 UI 设计中出场频率最高的元素之一。在《奇妙科学乐园》中,TopicCard 负责在科普列表页(Topics.ets)中以列表形式展示所有科普文章。它需要同时承载封面图片、分类标签(Badge)、文章标题、内容摘要、阅读量五大信息要素,并在有限的卡片空间中做到层次分明、视觉舒适。

这看似简单的卡片,背后却涉及多个关键布局技术的协作:Stack 层叠布局实现图片上悬浮分类标签、Column 纵向布局组织文字信息区、Row 横向布局排列底部统计行、圆角裁剪 clip 确保图片不溢出卡片边界。本文将从 Props 设计、混合布局、分类 Badge、点击事件回调四个维度,完整拆解 TopicCard 的实现细节。

源码仓库https://atomgit.com/2301_79280419/WonderSciencePark


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握 ArkTS 组件 Props 设计模式:@Prop 传递数据对象 + 可选事件回调
  • ✅ 运用 Stack + Column + Row 混合布局实现复杂卡片结构
  • ✅ 使用 clip(true) 圆角裁剪防止图片溢出卡片边界
  • ✅ 实现分类 Badge 标签:半透明背景 + 圆角胶囊样式
  • ✅ 设计可选点击回调 onItemClick,遵循可选调用安全模式
  • ✅ 使用 textOverflow + maxLines 控制长文本截断显示

💡 需求分析

TopicCard 组件的功能定位

TopicCard 是科普文章列表页的核心展示单元,每当用户进入"科普知识"页面,就会看到一列整齐排列的 TopicCard。它的功能定位如下:

功能要素描述UI 表现
封面图片展示文章所属分类的封面图Stack 容器中 110vp 高度的 Image
分类标签标识文章属于哪个科学分类图片左上角悬浮的半透明 Badge
文章标题显示科普文章的标题15fp 加粗文字,单行截断
内容摘要展示文章前两段内容的缩略12fp 灰色文字,单行截断
阅读量显示文章被阅读的次数底部行左侧,附带眼睛图标
点击交互点击卡片跳转到文章详情onClick 回调传递 Topic 对象
卡片容器圆角白色卡片,1px 边框borderRadius(16) + border

组件 Props 设计需求

TopicCard 作为一个可复用的展示组件,需要满足以下 Props 需求:

Props 名称类型必填默认值说明
topicTopic空 Topic 对象科普文章数据对象
onItemClick(topic: Topic) => voidundefined点击卡片的回调函数

设计决策:为什么 topic 使用 @Prop 而不是 @Link?因为 TopicCard 是纯展示组件,不需要修改 Topic 数据,只需要接收并展示。遵循"单向数据流"原则,@Prop 足矣。

卡片信息层次分析

从视觉设计角度,TopicCard 的信息层次分为三个区域:

┌─────────────────────────────┐ │ ┌─────────────────────────┐ │ │ │ 封面图片区域 │ │ ← 第一层:视觉吸引区 │ │ ┌──────────┐ │ │ │ │ │ 太空探索 │ ← Badge │ │ │ │ └──────────┘ │ │ │ └─────────────────────────┘ │ │ │ │ 为什么太阳会发光? │ ← 第二层:核心信息区 │ 太阳是一颗巨大的恒星...│ 标题 + 摘要 │ │ │ 👁 2.3k 阅读 → │ ← 第三层:辅助信息区 │ │ 阅读量 + 跳转箭头 └─────────────────────────────┘

🛠️ 核心实现

步骤1: 数据模型与默认值工厂函数

功能说明

TopicCard 依赖Topic数据模型来渲染内容。在组件内部,我们需要一个工厂函数getDefaultTopic()来生成安全的默认值,防止在数据尚未加载时组件渲染异常。这与我们之前在 Skeleton 骨架屏组件中的设计思路一脉相承——先有兜底值,再等真实数据

源码实现
//文件路径:entry/src/main/ets/model/Topic.ets /** * 冷知识数据模型 */ export interface FunFact { title: string;//冷知识标题 content: string;//冷知识内容 } /** * 科普文章数据模型 * 包含文章的完整信息:标题、分类、内容段落、冷知识、动画类型等 */ export interface Topic { id: number;//文章唯一标识 title: string;//文章标题 category: string;//分类ID(如'space') categoryName: string;//分类名称(如'太空探索') categoryColor: string;//分类主题色 icon: string;//分类图标emoji gradientStart: string;//渐变色起始值 gradientEnd: string;//渐变色结束值 content: string[];//文章内容段落数组 funFacts: FunFact[];//冷知识数组 animationType: string;//动画类型标识 has3DModel: boolean;//是否有3D模型 readTime: number;//预估阅读时长(分钟) readCount: number;//阅读量 difficulty:'easy'|'medium'|'hard';//难度等级 }
// 文件路径:entry/src/main/ets/components/topic/TopicCard.etsimport{Topic,FunFact}from'../../model/Topic';import{Category}from'../../model/Category';import{ scienceData }from'../../viewmodel/ScienceData';import{ThemeColors}from'../../constants/AppConstants';import{FormatUtil}from'../../utils/FormatUtil';/** * 生成默认的空 Topic 对象 * 用于组件初始化时的兜底值,避免 undefined 渲染异常 *@returns空的 Topic 对象,所有字段为初始值 */functiongetDefaultTopic():Topic{constemptyFacts:FunFact[] = [];// 空冷知识数组constemptyContent: string[] = [];// 空内容数组consttopic:Topic= {id:0,title:'',category:'',categoryName:'',categoryColor:'',icon:'',gradientStart:'#ffffff',gradientEnd:'#ffffff',content: emptyContent,funFacts: emptyFacts,animationType:'',has3DModel:false,readTime:0,readCount:0,difficulty:'easy'};returntopic; }
设计要点
设计决策说明对比分析
工厂函数 vs 内联默认值使用独立工厂函数,逻辑清晰,可复用❌ 内联默认值会导致组件声明臃肿
空数组初始化emptyFactsemptyContent单独声明❌ 直接赋值[]会导致类型推断丢失
difficulty 默认值设为'easy',符合字面量联合类型约束❌ 不设默认值会导致编译错误
id 默认为 00 作为"无数据"标识,配合条件渲染使用✅ 语义清晰,便于判断

步骤2: 组件声明与 Props 定义

功能说明

使用@Component装饰器声明 TopicCard 为可复用组件,通过@Prop接收父组件传递的 Topic 数据。点击回调onItemClick设计为可选属性(?修饰),这样父组件可以选择不传回调,组件内部通过if (this.onItemClick)安全调用。

源码实现
@ComponentexportstructTopicCard{// 接收父组件传递的文章数据,@Prop 单向传递,组件内不可修改@Proptopic:Topic=getDefaultTopic();// 可选的点击回调函数,父组件传入时点击卡片触发跳转onItemClick?:(topic: Topic) =>void;build() {// ... 构建UI} }
Props 使用的正确与错误对比
// ✅ 正确:@Prop 用于纯展示组件,单向数据流@Proptopic:Topic=getDefaultTopic();// ❌ 错误:展示组件使用 @Link,违反单向数据流原则@Linktopic:Topic;// TopicCard 不需要修改数据,@Link 是多余的// ✅ 正确:可选回调使用 ? 修饰符,内部安全判断onItemClick?:(topic: Topic) =>void;// ❌ 错误:强制必传回调,降低组件灵活性onItemClick:(topic: Topic) =>void;// 某些场景可能不需要点击跳转// ✅ 正确:父组件传入回调TopicCard({topic: topic,onItemClick:(t: Topic) =>this.goToTopicDetail(t) })// ❌ 错误:直接在组件内部硬编码路由跳转,破坏组件复用性.onClick(() =>{ router.pushUrl({url:'pages/TopicDetail'});// 组件不应感知路由细节})

步骤3: 封面图片区——Stack 层叠 + 分类 Badge

功能说明

卡片顶部是封面图片区域,使用 Stack 容器将图片和分类标签层叠在一起。分类 Badge 以半透明黑色背景悬浮在图片左上角,采用胶囊形状(borderRadius: 9999),既不遮挡主体画面,又能清晰标识文章分类。

源码实现
build(){Column(){// ===== 第一层:封面图片 + 分类 Badge(Stack 层叠布局)=====Stack({alignContent: Alignment.TopStart }){// 底层:分类封面图片Image(this.getCategoryCover()) .width('100%') .height(110) .objectFit(ImageFit.Cover);// 等比裁剪填满,不留白边// 上层:分类标签 BadgeText(this.topic.categoryName).fontSize(11).fontColor(ThemeColors.TEXT_WHITE)// 白色文字.backgroundColor('rgba(0, 0, 0, 0.4)')// 半透明黑色背景.padding({ left:10, right:10, top:4, bottom:4}) .borderRadius(9999)// 胶囊形状.margin({ top:10, left:10});// 距离左上角内边距} .width('100%');// ... 第二层和第三层代码} }
Stack 对齐方式对比
// ✅ 正确:TopStart 对齐,Badge 自然定位在左上角Stack({ alignContent: Alignment.TopStart }){ Image(...) Text('太空探索').margin({top:10, left:10});// 相对左上角偏移}// ❌ 错误:Center 对齐,需要额外计算偏移量Stack({ alignContent: Alignment.Center }){ Image(...) Text('太空探索')// 居中对齐,不自然,需要复杂的 position 定位}// ❌ 错误:BottomEnd 对齐,Badge 跑到右下角Stack({ alignContent: Alignment.BottomEnd }){ Image(...) Text('太空探索')// 语义上 Badge 应在左上角}
分类 Badge 样式对比
//✅ 正确:半透明背景 + 圆角胶囊 + 白色文字 .backgroundColor('rgba(0, 0, 0, 0.4)') .borderRadius(9999) .fontColor(ThemeColors.TEXT_WHITE)//❌ 错误:纯黑色背景,遮挡过多画面 .backgroundColor('#000000')//过于突兀,破坏图片美感//❌ 错误:无圆角矩形,视觉生硬 .borderRadius(0)//缺少圆润感,不适合儿童应用//❌ 错误:使用固定宽高,不同分类名称长度不同会截断 .width(60).height(20)//"海洋生物"可能显示不全//应该用 padding + borderRadius(9999) 自适应宽度

步骤4: 封面图片动态获取——getCategoryCover 方法

功能说明

卡片的封面图片并非来自 Topic 数据本身,而是根据 Topic 的 category 字段,从 ScienceData 服务中查询对应 Category 的 topicCoverImage 资源。这样做的优势是:同一分类下的所有文章共享同一张封面图,减少了资源冗余

源码实现
/** * 根据文章的分类ID获取分类封面图片资源 * 优先从 ScienceData 服务中查询,查不到则使用默认太阳图片 * @returns 图片资源引用 */privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category ? category.topicCoverImage :$r('app.media.topic_sun'); }
图片获取策略对比
// ✅ 正确:通过 ScienceData 服务查询,有兜底默认值privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category ? category.topicCoverImage :$r('app.media.topic_sun'); }// ❌ 错误:无兜底值,category 为空时直接崩溃privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category.topicCoverImage;// category 可能为 undefined}// ❌ 错误:直接返回固定图片,忽略分类差异privategetCategoryCover(): Resource { return$r('app.media.topic_sun');// 所有分类都显示太阳图片}// ❌ 错误:在 build() 方法中内联逻辑,违反单一职责Image(scienceData.getCategoryById(this.topic.category)?.topicCoverImage ??$r('app.media.topic_sun'))// build() 方法中不应包含复杂的三元运算和可选链

步骤5: 核心信息区——标题 + 摘要 + 阅读量

功能说明

图片区域下方是文字信息区,使用 Column 纵向排列三个要素:文章标题(加粗)、内容摘要(灰色)、底部统计行(阅读量 + 箭头)。所有文字都设置了maxLines(1)textOverflow(TextOverflow.Ellipsis),确保长文本不会撑破卡片布局。

源码实现
// ===== 第二层 + 第三层:文字信息区域 =====Column(){// 文章标题Text(this.topic.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(ThemeColors.TEXT_PRIMARY)// #333333 主文字色.width('100%') .margin({ bottom:6}) .maxLines(1)// 最多显示1行.textOverflow({overflow: TextOverflow.Ellipsis });// 超出显示省略号// 内容摘要Text(this.getTopicSummary()) .fontSize(12).fontColor(ThemeColors.TEXT_SECONDARY)// #666666 次要文字色.width('100%') .margin({ bottom:8}) .maxLines(1).textOverflow({overflow: TextOverflow.Ellipsis });// 底部统计行Row(){// 阅读量Text('👁 ' + FormatUtil.formatReadCount(this.topic.readCount)+ ' 阅读') .fontSize(12).fontColor(ThemeColors.TEXT_TERTIARY)// #999999 三级文字色.layoutWeight(1);// 占满剩余空间// 跳转箭头Text('→').fontSize(16).fontColor(ThemeColors.PRIMARY);// #ff6b6b 主题色} .width('100%'); } .padding(12) .width('100%');
摘要生成逻辑——getTopicSummary 方法
/** * 获取文章摘要文本 * 优先拼接前两段内容作为摘要,如果只有一段则用一段,无内容返回空字符串 *@returns摘要文本 */privategetTopicSummary(): string {if(this.topic.content &&this.topic.content.length >0) {if(this.topic.content.length >=2) {// 拼接前两段作为摘要,展示更多预览信息returnthis.topic.content[0] +this.topic.content[1]; }// 只有一段内容时,直接返回该段returnthis.topic.content[0]; }return''; }
文本截断处理对比
// ✅ 正确:maxLines + textOverflow 组合使用,优雅处理长文本Text(this.topic.title).maxLines(1).textOverflow({overflow: TextOverflow.Ellipsis });// ❌ 错误:不设置截断,长标题会撑宽卡片Text(this.topic.title).fontSize(15).fontWeight(FontWeight.Bold)// 缺少 maxLines 和 textOverflow,"为什么太阳会发光发热,它是如何形成..."会溢出// ❌ 错误:使用固定宽度限制,不同屏幕尺寸下表现不一致Text(this.topic.title).constraintSize({maxWidth: 200 })// 硬编码宽度,不适配不同屏幕// ✅ 正确:使用 layoutWeight 让阅读量占满空间,箭头自然靠右Text('👁 2.3k 阅读').layoutWeight(1);Text('→')// 不设 layoutWeight,保持固有宽度// ❌ 错误:使用 Blank() 组件填充空白Text('👁 2.3k 阅读')Blank()// 多此一举,layoutWeight(1) 已足够Text('→')

步骤6: 卡片容器——圆角裁剪与整体样式

功能说明

TopicCard 的最外层 Column 充当卡片容器,设置了白色背景、16vp 圆角、1px 边框和 12vp 底部间距。关键点是使用clip(true)开启圆角裁剪,确保 Stack 中的图片不会溢出卡片的圆角边界。

源码实现
build() { Column() {//封面图片区 Stack({ alignContent: Alignment.TopStart }) {/* ... */}//文字信息区 Column() {/* ... */} } .width('100%') .backgroundColor(ThemeColors.BG_PRIMARY)//白色背景 .borderRadius(16)//16vp 圆角 .border({ width:1, color: ThemeColors.BORDER_COLOR })//1px 边框 .clip(true)//圆角裁剪,关键! .margin({ bottom:12})//卡片间距 .onClick(() => {//安全调用可选回调if(this.onItemClick) { this.onItemClick(this.topic); } }); }
clip(true) 的必要性对比
// ✅ 正确:使用 clip(true),图片被圆角裁剪Column(){Stack(){Image($r('app.media.topic_sun')) .width('100%').height(110).objectFit(ImageFit.Cover); }// ...} .borderRadius(16).clip(true)// 图片被限制在圆角内,左上角和右上角不会出现直角溢出// ❌ 错误:缺少 clip(true),图片溢出圆角Column(){Stack(){Image($r('app.media.topic_sun')) .width('100%').height(110).objectFit(ImageFit.Cover); } } .borderRadius(16)// 没有 clip(true),图片的四个角会以直角突出,破坏圆角效果
整体布局结构图
Column (卡片容器) ├── Stack (封面区, alignContent: TopStart) │ ├── Image (分类封面,100%x110) │ └── Text (分类Badge, 悬浮左上角) ├── Column (信息区, padding:12) │ ├── Text (文章标题,15fp Bold) │ ├── Text (内容摘要,12fp) │ └── Row (统计行) │ ├── Text (阅读量, layoutWeight:1) │ └── Text (箭头 →)

步骤7: 父组件集成——在 Topics 列表页中使用 TopicCard

功能说明

TopicCard 作为子组件,在科普列表页Topics.ets中通过 LazyForEach 懒加载渲染。父组件通过topic属性传递数据,通过onItemClick回调处理跳转逻辑。这种"数据 + 回调"的组件通信模式是 HarmonyOS 组件设计的标准范式。

源码实现
//文件路径:entry/src/main/ets/pages/Topics.ets//在列表中使用 TopicCard//列表区域 List() { ListItem() { Row() { Text('共 '+ this.topicList.length +' 篇文章') .fontSize(13) .fontColor(ThemeColors.TEXT_SECONDARY); } .width('100%') .margin({ bottom:12}); }//使用 LazyForEach 懒加载 TopicCard LazyForEach(this.topicDataSource, (topic: Topic) => { ListItem() { TopicCard({ topic: topic,//传递文章数据 onItemClick: (t: Topic) => this.goToTopicDetail(t)//点击跳转回调 }); } }, (topic: Topic) => topic.id.toString()) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .padding(16) .backgroundColor(ThemeColors.BG_SECONDARY);
// 跳转到文章详情页的方法goToTopicDetail(topic: Topic):void{constparams: RouterParams = { topicId: topic.id };constoptions: RouterOptions = { url: RouteUrls.TOPIC_DETAIL,params:params}; RouterUtil.pushUrl(options,'Topics'); }
父组件使用方式对比
// ✅ 正确:传递 topic + 回调,数据流清晰TopicCard({topic: topic,onItemClick:(t: Topic) =>this.goToTopicDetail(t) })// ❌ 错误:不传回调,点击无响应(虽然不会崩溃,但交互体验差)TopicCard({topic: topic })// ❌ 错误:在 ForEach 中直接内联复杂逻辑TopicCard({topic: topic,onItemClick:() =>{// 大量逻辑写在这里,不利于维护constparams = {topicId: topic.id};RouterUtil.pushUrl({url:RouteUrls.TOPIC_DETAIL, params }); } })// ✅ 正确:使用 LazyForEach 懒加载,性能更优LazyForEach(this.topicDataSource,(topic: Topic) =>{ListItem() {TopicCard({topic: topic,onItemClick: ... }); } },(topic: Topic) =>topic.id.toString())// ❌ 错误:使用普通 ForEach,一次性渲染所有卡片ForEach(this.topicList,(topic: Topic) =>{ListItem() {TopicCard({topic: topic,onItemClick: ... }); } })// 当文章数量多时,所有卡片同时渲染,首屏性能差

🔍 关键技术详解

6.1 @Prop 装饰器深度理解

@Prop 是 ArkTS 中用于父子组件单向数据传递的装饰器。它的核心特性是值拷贝——父组件传递的值会被拷贝一份到子组件内部,子组件对 @Prop 变量的修改不会影响父组件。

特性@Prop@Link@Provide/@Consume
数据方向父 → 子(单向)父 ↔ 子(双向)跨层级传递
是否拷贝是(值拷贝)否(引用同步)否(引用同步)
适用场景纯展示组件需要回写父组件深层嵌套共享
TopicCard 适用性✅ 非常适合❌ 不需要❌ 不需要
// @Prop 的值拷贝特性示例@Componentstruct ParentComponent {@StateparentTopic: Topic = getDefaultTopic(); build() { TopicCard({ topic: this.parentTopic })// parentTopic 的值被拷贝给 TopicCard 内部的 topic// TopicCard 内部修改 topic 不会影响 parentTopic} }

6.2 可选回调的安全调用模式

onItemClick?: (topic: Topic) => void中的?表示该属性可能为 undefined。在调用前必须进行存在性判断。

// ✅ 正确:先判断再调用.onClick(() => {if(this.onItemClick) {this.onItemClick(this.topic); } })// ❌ 错误:直接调用,可能抛出 TypeError.onClick(() => {this.onItemClick!(this.topic);// 非空断言操作符 ! 危险})// ✅ 正确:使用可选链操作符(更简洁的写法).onClick(() => {this.onItemClick?.(this.topic); })

6.3 FormatUtil 阅读量格式化工具

TopicCard 中使用了FormatUtil.formatReadCount()来格式化阅读量数字,将大数字转换为更友好的显示格式。

// 文件路径:entry/src/main/ets/utils/FormatUtil.etsexportclassFormatUtil {/** * 格式化数字显示 * 大于1万显示为 "X.X万" * 大于1千显示为 "X.Xk" * 否则显示原数字 */static formatNumber(num:number):string{if(num >=10000) { return (num/10000).toFixed(1)+'万'; }elseif(num >=1000) { return (num/1000).toFixed(1)+'k'; } return num.toString(); } static formatReadCount(count:number):string{ returnFormatUtil.formatNumber(count); } }// 使用示例:// 2300 → "2.3k"// 34500 → "3.5万"// 856 → "856"

⚠️ 常见问题与排查

问题1: 图片未显示或显示异常

现象原因解决方案
图片区域空白getCategoryCover() 返回了无效资源检查 ScienceData 是否已初始化,Category 的 topicCoverImage 是否正确
图片拉伸变形objectFit 未设置为 Cover添加.objectFit(ImageFit.Cover)
图片溢出卡片圆角未设置 clip(true)在卡片容器 Column 上添加.clip(true)

问题2: 分类 Badge 不显示

现象原因解决方案
Badge 文字为空topic.categoryName 为空字符串确认数据源中 categoryName 字段已正确赋值
Badge 背景色不对rgba 值设置错误使用'rgba(0, 0, 0, 0.4)'标准格式
Badge 位置偏移Stack 对齐方式不对确认使用Alignment.TopStart

问题3: 点击卡片无响应

现象原因解决方案
点击没反应父组件未传 onItemClick 回调父组件添加onItemClick: (t) => this.goToDetail(t)
回调执行但跳转失败RouterUtil 参数有误检查 RouteUrls.TOPIC_DETAIL 路径是否正确

📊 性能优化

LazyForEach 懒加载优化

TopicCard 在列表中使用 LazyForEach 而非 ForEach,这是关键的性能优化点:

场景:100篇文章列表 ❌ ForEach:一次渲染100个TopicCard实例-首屏渲染时间:约800ms-内存占用:高(100个组件树) ✅ LazyForEach:仅渲染可视区域的TopicCard-首屏渲染时间:约150ms-内存占用:低(约5-8个组件树)-滚动时动态创建/销毁组件

图片资源复用策略

同一分类下的多篇文章共享同一张封面图,HarmonyOS 的图片缓存机制会自动复用已解码的图片资源:

太空探索分类(3篇文章) ├── 为什么太阳会发光? → topic_sun.jpg← 已解码缓存 ├── 月球上的脚印 → topic_sun.jpg← 命中缓存,零开销 └── 星星的秘密 → topic_sun.jpg← 命中缓存,零开销

🧪 测试验证

测试用例清单

测试场景预期结果验证方式
正常数据渲染标题、摘要、阅读量、Badge 全部正确显示目视比对 JSON 数据
空数据兜底显示空卡片,不崩溃传入 getDefaultTopic()
长标题截断超过一行显示省略号传入超长 title 字符串
点击跳转跳转到正确详情页点击卡片,检查跳转 URL
无回调点击不崩溃,无跳转不传 onItemClick,点击卡片
大阅读量格式化10000+ 显示为 "X.X万"传入 readCount: 23000
分类封面回退无分类时显示默认太阳图传入 category: 'unknown'

📝 总结

本文完整拆解了 TopicCard 文章卡片组件的实现细节,涵盖了以下核心技术点:

技术点实现方式设计原则
Props 设计@Prop topic + 可选 onItemClick单向数据流 + 回调分离
混合布局Stack(Column, Row) 嵌套层次分明,职责清晰
圆角裁剪clip(true) + borderRadius(16)容器级裁剪,子元素无忧
分类 Badge半透明背景 + 胶囊圆角视觉层次不遮挡主体
文本截断maxLines(1) + textOverflow防止长文本溢出布局
阅读量格式化FormatUtil.formatReadCount大数字友好显示
组件集成LazyForEach + 回调跳转性能优化 + 交互解耦

TopicCard 的设计体现了"高内聚、低耦合"的组件设计原则:组件内部自包含所有展示逻辑,通过 Props 接收数据、通过回调通知事件,不依赖任何外部路由或状态管理。这使得 TopicCard 可以在任何需要展示科普文章摘要的场景中复用。


🔗 相关链接

  • 项目源码Atomgit仓库
  • 上一篇HarmonyOS应用<奇妙科学乐园>开发第43篇:Banner轮播图组件——Swiper实战与自动滚动
  • 下一篇HarmonyOS应用<奇妙科学乐园>开发第45篇:Stack层叠布局——Hero图片区与渐变遮罩实现