【鸿蒙原生应用开发实战】第三篇:列表页与标签筛选功能 --- 打造高效的天体列表
前言
列表页是移动App中最常见也最重要的页面类型之一。在"宇宙探索"App中,CelestialPage(天体列表页)承担着展示天体和分类筛选的核心职责。
本篇我们将深入实现:
- 5个分类标签的切换筛选
- 天体卡片组件的完整设计
- 收藏交互的即时反馈
- 列表渲染性能优化要点
一、页面功能总览
CelestialPage 实现了三个核心功能:
- 标签导航 --- 顶部5个标签(全部/行星/恒星/星系/星云),点击切换筛选
- 列表展示 --- 按标签筛选结果展示天体卡片
- 收藏交互 --- 每个卡片可以直接收藏/取消收藏
页面结构
┌──────────────────────────────┐
│ ← 天体列表 │ ← 顶部导航栏
├──────────────────────────────┤
│ [全部] [行星] [恒星] [星系] [星云]│ ← 标签栏
├──────────────────────────────┤
│ ██ 太阳 │ ← 天体卡片
│ Sun · 恒星 │ (颜色标识条 + 信息 + 收藏)
│ 太阳是太阳系的中心... │
│ ☆ │
├──────────────────────────────┤
│ ██ 地球 │
│ Earth · 行星 │
│ 地球是太阳系中唯一... │
│ ★ │ ← 已收藏状态
├──────────────────────────────┤
│ ... │
└──────────────────────────────┘
二、完整代码实现
2.1 接口定义
typescript
import router from '@ohos.router';
import { CelestialData, CELESTIAL_LIST, FavoriteManager } from '../model/CelestialData';
interface TabItem {
label: string;
type: string;
}
TabItem 接口:label 是显示文本,type 是筛选类型值。
2.2 天体卡片组件 --- CelestialCard
typescript
@Component
struct CelestialCard {
item: CelestialData = {
id: 0, name: '', englishName: '', type: '', description: '',
mass: '', diameter: '', distance: '', temperature: '', fact: '',
color: '#FFFFFF', isFavorite: false
};
@State isFav: boolean = false;
aboutToAppear(): void {
this.isFav = FavoriteManager.isFavorite(this.item.id);
}
toggleFav(): void {
const newState = FavoriteManager.toggle(this.item.id);
this.isFav = newState;
this.item.isFavorite = newState;
}
build() {
Row() {
// 颜色标识条 --- 不同天体不同颜色
Column()
.width(6)
.height('100%')
.backgroundColor(this.item.color)
.borderRadius(3);
// 信息区域
Column() {
Row() {
Text(this.item.name)
.fontSize($r('app.float.app_body_size'))
.fontColor($r('app.color.app_color_white'))
.fontWeight(FontWeight.Bold);
Text(this.item.englishName)
.fontSize($r('app.float.app_caption_size'))
.fontColor($r('app.color.app_color_text_secondary'))
.margin({ left: 8 });
}
.alignItems(VerticalAlign.Bottom);
Text(this.item.type)
.fontSize($r('app.float.app_caption_size'))
.fontColor(this.item.color)
.margin({ top: 4 });
Text(this.item.description.length > 50 ?
this.item.description.substring(0, 50) + '...' :
this.item.description)
.fontSize($r('app.float.app_caption_size'))
.fontColor($r('app.color.app_color_text_secondary'))
.maxLines(2)
.lineHeight(18)
.margin({ top: 6 });
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ left: 12, right: 8, top: 12, bottom: 12 });
// 收藏按钮
Text(this.isFav ? '★' : '☆')
.fontSize(24)
.fontColor(this.isFav ?
$r('app.color.app_color_favorite') :
$r('app.color.app_color_unfavorite'))
.onClick((event: ClickEvent) => {
this.toggleFav();
})
.padding({ right: 12 });
}
.width('100%')
.height(110)
.backgroundColor($r('app.color.app_color_card'))
.borderRadius($r('app.float.app_card_radius'))
.margin({ bottom: 12 })
.onClick(() => {
router.pushUrl({
url: 'pages/DetailPage',
params: { id: this.item.id }
});
});
}
}
2.3 卡片设计要点
1. 颜色标识条
左侧6px宽的竖条,使用 this.item.color 颜色。每个天体有专属色:
- 太阳 →
#FF6B35(橙色) - 地球 →
#4B7B8A(蓝绿色) - 火星 →
#C1440E(红色) - 黑洞 →
#2D2D3D(深灰)
视觉上让卡片更生动,同时帮助用户快速识别天体类型。
2. 收藏按钮的点击事件隔离
typescript
Text(this.isFav ? '★' : '☆')
.onClick((event: ClickEvent) => {
this.toggleFav(); // 只触发收藏切换
})
收藏按钮的点击事件和卡片的点击事件是独立的:
- 点击收藏按钮 → 只切换收藏状态
- 点击卡片其他区域 → 跳转到详情页
这是通过 event 事件冒泡机制实现的------收藏按钮消费了点击事件,不会冒泡到卡片容器。
3. 描述文本截断
typescript
Text(this.item.description.length > 50 ?
this.item.description.substring(0, 50) + '...' :
this.item.description)
.maxLines(2)
.lineHeight(18)
双重截断保障:代码层面截取前50字符,样式层面限制最多2行,确保UI整齐。
2.4 主页面 --- CelestialPage
typescript
@Entry
@Component
struct CelestialPage {
@State tabs: TabItem[] = [
{ label: '全部', type: '全部' },
{ label: '行星', type: '行星' },
{ label: '恒星', type: '恒星' },
{ label: '星系', type: '星系' },
{ label: '星云', type: '星云' }
];
@State activeTab: string = '全部';
@State filteredList: CelestialData[] = CELESTIAL_LIST;
private filterType: string = '';
aboutToAppear(): void {
// 从路由参数获取筛选类型(从首页分类入口跳转过来时)
const params = router.getParams() as Record<string, Object>;
if (params && params['filterType'] !== undefined) {
this.filterType = String(params['filterType']);
this.activeTab = this.filterType;
}
this.applyFilter();
}
onPageShow(): void {
this.applyFilter(); // 返回此页面时重新应用筛选(刷新收藏状态)
}
selectTab(type: string): void {
this.activeTab = type;
this.applyFilter();
}
applyFilter(): void {
if (this.activeTab === '全部') {
this.filteredList = CELESTIAL_LIST;
} else {
const arr: CelestialData[] = [];
for (let i = 0; i < CELESTIAL_LIST.length; i++) {
if (CELESTIAL_LIST[i].type === this.activeTab) {
arr.push(CELESTIAL_LIST[i]);
}
}
this.filteredList = arr;
}
}
build() {
Column() {
// ===== 顶部导航栏 =====
Row() {
Text('←')
.fontSize(24)
.fontColor($r('app.color.app_color_white'))
.onClick(() => { router.back(); });
Text('天体列表')
.fontSize($r('app.float.app_subtitle_size'))
.fontColor($r('app.color.app_color_white'))
.fontWeight(FontWeight.Bold)
.margin({ left: 12 });
}
.width('100%')
.padding({ left: 16, top: 12, bottom: 12 });
// ===== 标签栏 =====
Row() {
ForEach(this.tabs, (tab: TabItem) => {
Text(tab.label)
.fontSize($r('app.float.app_small_size'))
.fontColor(this.activeTab === tab.type ?
$r('app.color.app_color_accent') :
$r('app.color.app_color_tab_inactive'))
.fontWeight(this.activeTab === tab.type ?
FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.activeTab === tab.type ?
'rgba(255, 215, 0, 0.15)' : 'transparent')
.borderRadius(16)
.onClick(() => { this.selectTab(tab.type); });
})
}
.width('100%')
.padding({ left: 16, bottom: 12 });
// ===== 列表区域 =====
Scroll() {
Column() {
ForEach(this.filteredList, (item: CelestialData) => {
CelestialCard({ item: item })
});
}
.width('100%')
.padding({ left: 16, right: 16 });
}
.layoutWeight(1);
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.app_color_background'));
}
}
三、标签筛选机制详解
3.1 状态变量设计
typescript
@State activeTab: string = '全部'; // 当前激活的标签
@State filteredList: CelestialData[] = CELESTIAL_LIST; // 筛选后的列表
activeTab--- 控制哪个标签高亮,同时作为筛选依据filteredList--- 筛选后的数据,驱动列表渲染
3.2 筛选核心逻辑
typescript
applyFilter(): void {
if (this.activeTab === '全部') {
this.filteredList = CELESTIAL_LIST; // 显示全部
} else {
// 手动遍历筛选(避免使用 filter 等ES6+方法)
const arr: CelestialData[] = [];
for (let i = 0; i < CELESTIAL_LIST.length; i++) {
if (CELESTIAL_LIST[i].type === this.activeTab) {
arr.push(CELESTIAL_LIST[i]);
}
}
this.filteredList = arr;
}
}
为什么要用 for 循环而不是 filter 方法?
ArkTS 严格模式对 ES6+ 的数组高阶方法支持有限。在 API 23 下,
filter、map等方法的类型推断可能会出问题。使用传统的for循环是更稳妥的方案。
3.3 标签激活状态样式
typescript
.fontColor(this.activeTab === tab.type ?
$r('app.color.app_color_accent') : // 激活:金色
$r('app.color.app_color_tab_inactive')) // 未激活:灰色
.backgroundColor(this.activeTab === tab.type ?
'rgba(255, 215, 0, 0.15)' : 'transparent') // 激活:半透明金色背景
.borderRadius(16) // 椭圆胶囊效果
视觉反馈三要素:
| 属性 | 激活态 | 未激活态 |
|---|---|---|
| 文字颜色 | 金色 #FFD700 |
灰色 #555555 |
| 字体粗细 | Bold | Normal |
| 背景色 | 金色半透明 | 透明 |
3.4 路由入口支持
页面支持两种进入方式:
方式一:从首页分类入口进入
用户点击"行星"分类卡片 → 自动筛选"行星"标签
typescript
// Index.ets 中
CategoryCard.onClick(() => {
router.pushUrl({
url: 'pages/CelestialPage',
params: { filterType: '行星' }
});
});
// CelestialPage 中接收
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
if (params && params['filterType'] !== undefined) {
this.filterType = String(params['filterType']);
this.activeTab = this.filterType; // 直接激活对应标签
}
this.applyFilter();
}
方式二:直接从天体列表中进入
通过底栏或收藏页"去探索"按钮进入 → 默认显示"全部"
四、onPageShow 的重要作用
typescript
onPageShow(): void {
this.applyFilter();
}
为什么 onPageShow 中也要调用 applyFilter()?
场景重现:
- 用户进入列表页,收藏了"地球"(☆ → ★)
- 点击"地球"卡片跳转到详情页
- 在详情页取消收藏"地球"(★ → ☆)
- 点击返回,回到列表页
- 此时列表页需要通过
onPageShow重新加载数据,刷新收藏状态
如果只用 aboutToAppear,步骤4返回后列表不会刷新,收藏状态还是旧的,造成数据显示不一致。
五、收藏交互的即时反馈
CelestialCard 组件内部维护了自己的收藏状态:
typescript
@Component
struct CelestialCard {
@State isFav: boolean = false;
aboutToAppear(): void {
this.isFav = FavoriteManager.isFavorite(this.item.id);
}
toggleFav(): void {
const newState = FavoriteManager.toggle(this.item.id);
this.isFav = newState; // @State 变化 → UI 自动刷新
this.item.isFavorite = newState; // 同步到数据对象
}
}
用户点击收藏按钮的完整链路:
用户点击 ★/☆
↓
toggleFav() 被调用
↓
FavoriteManager.toggle(id) → 数据层修改
↓
this.isFav = newState → @State 变量变化
↓
框架检测到 @State 变化 → 重新渲染 build()
↓
this.isFav ? '★' : '☆' → 图标变化
this.isFav ? 红色(#FF6B6B) : 灰色(#666666) → 颜色变化
整个过程完全由数据驱动,无需手动操作DOM!
六、ForEach 渲染要点
6.1 基本用法
typescript
ForEach(
this.filteredList, // 数据源
(item: CelestialData) => { // UI 生成函数
CelestialCard({ item: item })
}
)
6.2 性能优化建议
虽然当前列表只有10个条目,但了解优化方法有助于未来处理大数据量:
typescript
// 方式一:带 key(推荐,性能更优)
ForEach(
this.filteredList,
(item: CelestialData) => CelestialCard({ item: item }),
(item: CelestialData) => item.id.toString() // 唯一 key
)
// 方式二:不带 key
ForEach(
this.filteredList,
(item: CelestialData) => CelestialCard({ item: item })
)
当列表项顺序可能变化或数据量较大时,提供 key 可以让框架最小化DOM操作。
6.3 类型注解
ForEach 的回调函数参数必须显式标注类型:
typescript
// ✅ 正确
ForEach(this.filteredList, (item: CelestialData) => { ... })
// ❌ 错误 - 缺少类型注解
ForEach(this.filteredList, (item) => { ... })
七、完整页面效果
当用户从首页点击"行星"分类:
CelestialPage启动,接收filterType = '行星'- 标签栏高亮"行星"标签(金色)
applyFilter()筛选出6个行星:水星、金星、地球、火星、木星、土星- 列表中展示6个天体卡片,每个左侧有不同颜色的标识条
- 用户可以点击☆收藏任意天体
- 点击卡片跳转到详情页

八、本篇总结
本篇我们完成了 CelestialPage 的完整开发,核心收获:
- ✅ 标签筛选机制 ---
activeTab+applyFilter()实现分类切换 - ✅ CelestialCard 组件 --- 颜色标识条、信息展示、收藏按钮的完整设计
- ✅ 点击事件隔离 --- 收藏按钮和卡片点击各自独立
- ✅ 页面生命周期 ---
aboutToAppearvsonPageShow的区别与使用场景 - ✅ 路由参数传递 --- 从首页分类入口携带筛选参数跳转
- ✅ 数据驱动UI ---
@State isFav变化自动刷新收藏图标
下篇预告 :我们将开发最丰富的页面 --- DetailPage(天体详情页),包含动态数据切换、四个信息维度展示、趣味知识模块,以及收藏按钮的完整交互。
本篇涉及的文件:
entry/src/main/ets/pages/CelestialPage.ets--- 列表页与卡片组件entry/src/main/ets/model/CelestialData.ets--- 数据源