文章目录
-
- 为什么"死磕"这个新闻项目?
- [🚀 项目效果"卖家秀"](#🚀 项目效果“卖家秀”)
- 跟我一步步"肝"代码
-
- 第一步:设计新闻"身份证"(数据模型)
- [第二步:造"轮子"!@Component 自定义卡片](#第二步:造“轮子”!@Component 自定义卡片)
- [第三步:卡片"颜值担当"------图片区(Stack 布局)](#第三步:卡片“颜值担当”——图片区(Stack 布局))
- [第四步:卡片"灵魂"------内容区(Column + Row)](#第四步:卡片“灵魂”——内容区(Column + Row))
- 第四步(续):卡片的"脚"------底部信息栏(交互核心)
- [第五步:"组装" App!------主页面架构](#第五步:“组装” App!——主页面架构)
- 第六步:实现"横向滚动"的分类筛选条
- [第七步:"魔法"发生地!------ 响应式新闻列表](#第七步:“魔法”发生地!—— 响应式新闻列表)
- [😱 "萌新"踩坑(含泪)实录](#😱 “萌新”踩坑(含泪)实录)
-
- [巨坑 1:我改了"爹"的数据!(@Prop vs @State)](#巨坑 1:我改了“爹”的数据!(@Prop vs @State))
- [小坑 2:图片加载"翻车"](#小坑 2:图片加载“翻车”)
- [小坑 3:布局"偏心眼"](#小坑 3:布局“偏心眼”)
- "精装修"建议(下一步玩啥)
- 总结:你又"行"了!
摘要: 本文详细讲解HarmonyOS新闻卡片组件的完整开发流程,涵盖自定义组件设计、List列表渲染、分类筛选功能实现。通过具体代码示例,分享新闻数据模型构建、复杂布局技巧、父子组件通信机制以及交互体验优化。适合HarmonyOS初学者学习组件化开发和列表展示功能,帮助开发者快速掌握构建现代资讯类应用的核心技术。
标签: HarmonyOS 新闻卡片 自定义组件 List渲染 分类筛选 组件通信 移动开发
大家好!鸿蒙学习"肝帝"又上线了!今天继续我的 HarmonyOS 学习之旅,这次的挑战是------搞一个功能"全家桶"的新闻资讯 App。
这个项目包含了自定义组件 、List 渲染 、分类筛选等超多实用技巧,绝对是"进阶"必备!特别适合想从"游击队"变成"正规军",深入学习组件化开发的小伙伴!
为什么"死磕"这个新闻项目?
上次的"国风" Demo 只是开胃菜,这次我们要"来真的"了!
为啥选这个?因为"麻雀虽小,五脏俱全"啊!做一个资讯 App,我(和你们)可以一举拿下:
- ✅ 怎么"造零件" :掌握
@Component装饰器,造一个"高复用"卡片! - ✅ 怎么"刷列表" :精通
List+ForEach,还要学会用filter玩筛选! - ✅ 怎么"叠罗汉" :
Stack、Column、Row嵌套使用,搞定复杂布局! - ✅ 怎么"点个赞":实现点赞、分类这种真实的用户交互!
🚀 项目效果"卖家秀"
先上个"卖家秀"!想象一下:打开 App,一个专业的"今日头条"风界面映入眼帘。顶部是标题栏和(能横向滚动的)分类筛选条,下面是颜值超高的瀑布流新闻卡片。

每张卡片都有图有真相,标题、摘要、发布时间、阅读量、点赞功能一应俱全。点一下"科技",列表"唰"一下就只剩科技新闻,丝滑!
跟我一步步"肝"代码
第一步:设计新闻"身份证"(数据模型)

老规矩,"兵马未动,粮草先行"。写 App 之前,先得搞清楚咱们的数据长啥样。
typescript
class NewsItem {
id: number = 0;
title: string = '';
summary: string = '';
imageUrl: string = '';
category: string = '';
publishTime: string = '';
readCount: number = 0;
isLiked: boolean = false;
constructor(
id: number,
title: string,
summary: string,
imageUrl: string,
category: string,
publishTime: string,
readCount: number,
isLiked: boolean
) {
this.id = id;
this.title = title;
this.summary = summary;
this.imageUrl = imageUrl;
this.category = category;
this.publishTime = publishTime;
this.readCount = readCount;
this.isLiked = isLiked;
}
}
笔记:
这个 NewsItem 类就是咱们的新闻"身份证"模板。比上次的"国风"商品复杂了点,加了图片 URL、阅读量、是不是点过赞... 毕竟是新闻嘛,要素得齐全!
第二步:造"轮子"!@Component 自定义卡片
OK,核心中的核心来了!我们要"造轮子"------一个可复用的"新闻卡片"组件 buildNewsCard。

这次我们不用上次的 @Builder 了,而是用更"重量级"的 @Component。
为啥?因为 @Builder 只是个"UI 蓝图"(函数),而 @Component 是一个拥有自己生命周期和状态的"独立公民"(结构体)!这对于需要内部交互(比如点赞)的组件来说,至关重要。
typescript
@Component
struct buildNewsCard {
@Prop news: NewsItem // @Prop: "爹给的"数据(父组件传来)
@State localNews: NewsItem = new NewsItem(0, '', '', '', '', '', 0, false) // @State: "自己的"数据(内部状态)
aboutToAppear() {
// 这是组件"出生"时会调用的方法
if (this.news) {
// 把"爹给的"数据,复制一份到"自己的"地盘上
this.localNews = new NewsItem(
this.news.id,
this.news.title,
this.news.summary,
this.news.imageUrl,
this.news.category,
this.news.publishTime,
this.news.readCount,
this.news.isLiked
);
}
}
// ... build() 方法马上就来 ...
}
笔记:
注意看 @Prop 和 @State 的用法!@Prop 是"爹给的"(父组件传来的),@State 是"自己私有的"(内部状态)。
我们在 aboutToAppear 这个"出生"生命周期方法里,把"爹给的"news 数据"复制"一份到"自己的"localNews 里。这样,我们就可以在组件内部随便"折腾"(比如点赞)这个 localNews,还不会"坑爹"(污染父组件的数据)!
第三步:卡片"颜值担当"------图片区(Stack 布局)

"轮子"有了,开始画它的 build() 方法。一个卡片得有"颜值"担当,我们先用 Stack(堆叠)布局,把图片作为"背景板"。
typescript
// 这是 buildNewsCard 组件里的 build() 方法
build() {
Column({ space: 12 }) { // 用一个 Column 把图片和文字包起来
// 图片区域
Stack() {
// 网络图片
Image(this.localNews.imageUrl)
.width('100%')
.height(200)
.objectFit(ImageFit.Cover) // 让图片不变形地填满,裁掉多余的
.borderRadius(12)
.alt($r('app.media.avatar')) // 翻车(加载失败)时的占位图
// 分类标签
Text(this.localNews.category)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#007DFF')
.borderRadius(4)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.position({ x: 12, y: 12 }) // 精准"贴"在左上角
}
// ... 内容区(下一步) ...
}.backgroundColor('#FFFFFF').borderRadius(12) // 整个卡片的背景和圆角
}
笔记:
Stack 布局就是"叠叠乐",后写的会盖在先写的上面。我们用 position 把"分类标签"精准地"贴"在左上角。objectFit(ImageFit.Cover) 是个神技,能让各种比例的图片都好看!
第四步:卡片"灵魂"------内容区(Column + Row)

光有图不行,得有"灵魂"------标题和摘要。这部分紧跟在 Stack 后面,放在外层的 Column 里。
typescript
// ... Stack 布局结束 ...
// 内容区域
Column({ space: 12 }) {
// 标题区域
Text(this.localNews.title)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出2行变"..."
// 摘要区域
Text(this.localNews.summary)
.fontSize(14)
.fontColor('#666666')
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出3行变"..."
.lineHeight(20)
// ... 底部信息栏(下一步) ...
}
.padding({ left: 16, right: 16, bottom: 16 }) // 给内容区加点内边距
笔记:
这部分全是老朋友:fontSize、fontWeight、maxLines(最多两行)、textOverflow(超出部分变"...")。这套"组合拳"打出去,UI 一下子就清爽了。
第四步(续):卡片的"脚"------底部信息栏(交互核心)

卡片快完工了,还差个"脚"------底部信息栏。一个 Row(水平布局)搞定,左边放时间和阅读量,右边放点赞。
typescript
// ... 摘要区域结束 ...
// 底部信息栏
Row() {
// 左侧信息(发布时间、阅读量)
Column({ space: 2 }) {
Row({ space: 8 }) {
Image($r('app.media.shijian')).width(18).height(18)
Text(this.localNews.publishTime).fontSize(12).fontColor('#999999')
}
Row({ space: 8 }) {
Image($r('app.media.yuedu')).width(18).height(18)
Text(`${this.localNews.readCount}`).fontSize(12).fontColor('#999999')
}
}
.layoutWeight(1) // 自动"抢"走所有剩余空间,把点赞按钮挤到最右边
// 点赞按钮
Row({ space: 6 }) {
Image(this.localNews.isLiked ? $r('app.media.dianzan2') : $r('app.media.dianzan'))
.width(30).height(30)
Text(this.localNews.isLiked ? '已赞' : '点赞')
.fontSize(12)
.fontColor(this.localNews.isLiked ? '#007DFF' : '#999999')
}
.onClick(() => {
// 点击时,"反转"自己的内部状态
this.localNews.isLiked = !this.localNews.isLiked;
})
.backgroundColor(this.localNews.isLiked ? '#E6F2FF' : '#F5F5F5')
.borderRadius(6)
.padding({left: 8, right: 8, top: 4, bottom: 4}) // 给点赞按钮加点内边距
}
} // 内容区的 Column 结束
} // 整个卡片的 Column 结束
} // build() 方法结束
笔记:
layoutWeight(1) 又是神技!它让左边的信息栏"霸道"地占据所有剩余空间,从而把点赞按钮"挤"到最右边,完美实现两端对齐!
注意看"点赞"按钮!我们用了一个"三元运算符" (this.localNews.isLiked ? ... : ...)。如果 isLiked 是 true,就显示"已赞"图标和蓝色;如果是 false,就显示"点赞"图标和灰色。连背景色都换了!
onClick 时,我们只修改了 this.localNews 这个"内部状态",这就是 @Component + @State 的威力!
第五步:"组装" App!------主页面架构

"零件"造好了,现在开始"组装"我们的 App 主页面!@Entry 登场!
typescript
@Entry
@Component
export struct NewsCardDemo {
// 伪造一堆新闻数据,记得用你刚才定义的 NewsItem 类
@State newsList: NewsItem[] = [
new NewsItem(1, '鸿蒙新版发布!', '性能提升30%...', 'app.media.hm', '科技', '1小时前', 1024, false),
new NewsItem(2, 'xx明星演唱会', '现场燃爆...', 'app.media.star', '娱乐', '2小时前', 5890, true),
new NewsItem(3, '国足又...进球了!', '球员xx梅开二度...', 'app.media.gz', '体育', '3小时前', 888, false)
// ... 伪造更多数据 ...
];
@State selectedCategory: string = '全部'; // 用@State管理当前选中的分类
categories: string[] = ['全部', '科技', '娱乐', '体育', '财经'];
build() {
Column({ space: 0 }) {
// 顶部标题栏(这里我偷懒,你也可以用@Builder写一个)
Text('新闻资讯').fontSize(20).fontWeight(FontWeight.Bold).padding(16).width('100%')
// 分类筛选
this.buildCategoryFilter()
// 新闻列表
this.buildNewsList()
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// ... buildCategoryFilter 和 buildNewsList 马上就来 ...
}
笔记:
这个主页面也用 @State 来管理"全局"的新闻列表 newsList 和当前选中的分类 selectedCategory。build 方法里,我们用 Column 把页面分成了"上(标题)"、"中(筛选)"、"下(列表)"三个部分。
第六步:实现"横向滚动"的分类筛选条

分类筛选条是个高频需求。用 Scroll 包一个 Row,就能让它"横向滚动"。
typescript
// 在 NewsCardDemo struct 内部
@Builder
buildCategoryFilter() {
Scroll() { // 横向滚动容器
Row({ space: 12 }) {
ForEach(this.categories, (category: string) => {
Text(category)
.fontSize(16)
.fontColor(this.selectedCategory === category ? '#FFFFFF' : '#666666') // 选中高亮
.backgroundColor(this.selectedCategory === category ? '#007DFF' : '#F0F0F0') // 选中高亮
.borderRadius(20)
.padding({left: 16, right: 16, top: 8, bottom: 8})
.onClick(() => {
// 关键:点击时,更新@State变量
this.selectedCategory = category;
})
})
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.scrollBar(BarState.Off) // 关掉丑丑的滚动条
}
笔记:
又是"三元运算符"的胜利!this.selectedCategory === category ? ... : ...。
最最关键的是 onClick:点击谁,谁就高亮,同时把 @State 变量 this.selectedCategory 的值改成被点击的 category。
这个 @State 一变,奇迹发生了...(请看下一步)
第七步:"魔法"发生地!------ 响应式新闻列表

奇迹来了!还记得吗?ArkTS 是"响应式"的!当你点击分类条,@State 的 selectedCategory 一变,所有"依赖"了它的 UI 都会"自动"重新渲染!
buildNewsList 就"依赖"了它!
typescript
// 在 NewsCardDemo struct 内部
@Builder
buildNewsList() {
List({ space: 12 }) {
// 魔法!在这里用 filter 过滤数据!
ForEach(this.newsList.filter(item =>
// 如果选的是"全部",或者 item 的分类 匹配 选中的分类
this.selectedCategory === '全部' || item.category === this.selectedCategory
), (news: NewsItem) => {
ListItem() {
// 调用我们刚才辛辛苦苦"造的轮子"
buildNewsCard({ news: news })
}
}, (news: NewsItem) => news.id.toString()) // 用 id 作为唯一标识,提升性能
}
.width('100%')
.layoutWeight(1) // 占满剩余所有空间
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
笔记:
看 ForEach 里的 this.newsList.filter(...)!这就是"魔法"核心!
filter 方法会根据 this.selectedCategory 筛选出"应该"显示的新闻。
整个流程是:
- 你点击"科技"按钮。
onClick把@State变量selectedCategory改成了 "科技"。- ArkTS 框架发现
@State变了,马上"通知"所有用到它的地方。 buildNewsList重新执行build()。ForEach里的filter重新计算,这次只返回了category === '科技'的新闻。List自动更新!
丝滑!这就是"数据驱动 UI"的魅力!
😱 "萌新"踩坑(含泪)实录
"常在河边走,哪能不湿鞋"。下面是我(含泪)总结的"踩坑"经验,各位"萌新"请拿好,可以少走几公里弯路!
巨坑 1:我改了"爹"的数据!(@Prop vs @State)
- 现象: 我一开始在子组件
buildNewsCard里,想点赞时直接改this.news.isLiked... 结果,卒!要么不生效,要么"污染"了父组件。 - 血泪教训:
@Prop是"爹"给的,是只读的! 你不能在"儿子"组件里直接改"爹"的数据! - 正解: 就像我们前面做的,
aboutToAppear里把@Prop的news复制 给@State的localNews。组件内部只修改localNews,实现"自给自足"。
typescript
// 正确做法:使用内部状态管理
@State localNews: NewsItem = new NewsItem(0, '', '', '', '', '', 0, false)
aboutToAppear() {
if (this.news) {
this.localNews = new NewsItem(/* 复制数据 */);
}
}
小坑 2:图片加载"翻车"
- 现象: 网速不好时,图片加载失败,卡片上出现一个大"窟窿",丑爆了。
- 正解:
Image组件有两个好基友.alt()和.onError()。
typescript
Image(this.localNews.imageUrl)
.alt($r('app.media.avatar')) // 加载失败/加载中 显示的占位图
.onError(() => {
console.log(`图片加载失败: ${this.localNews.title}`);
})
小坑 3:布局"偏心眼"
- 现象: 底部信息栏,左边的日期和右边的点赞,挤在一起了,或者对不齐。
- 正解: 善用
layoutWeight(1)!给那个你希望它"尽可能伸展"的组件(比如我们左边的信息栏Column)加上它,它就会自动"抢"走所有剩余空间。
typescript
Column({ space: 2 }) {
// 左侧信息内容
}
.layoutWeight(1) // 占据剩余空间
"精装修"建议(下一步玩啥)
这个 Demo 只是个"毛坯房",想"精装修"?你可以试试:
- 搜索功能 :在标题栏下面加个搜索框,用
filter按"标题" (title.includes(searchText)) 搜索。 - 详情页面 :点击卡片,跳转到完整的新闻详情页(
Navigation组件该出场了)。 - 下拉刷新 :给
List加上下拉刷新功能,更新newsList里的数据。 - 上拉加载 :滚动到底部时,自动加载"下一页"数据(
onReachEnd事件)。
总结:你又"行"了!
呼------(擦汗)。这个项目"肝"下来,是不是感觉自己又"行"了?

我们从一个"空架子"开始,亲手"锻造"了数据模型,"封装"了高复用性的 @Component 卡片(还搞懂了 @Prop 和 @State 的"父子关系"),用 Stack 玩转了"叠罗汉"布局,最后用 @State 和 filter 联手实现了"响应式"筛选列表。
这已经是一个准专业 App 的雏形了!
从"国风" Demo 到这个新闻 App,你已经从"新手村"毕业,拿到了"高级组件"的徽章。继续保持这份热情,下一个项目,我们去挑战更酷的!