鸿蒙原生应用实战(二):游戏库列表与筛选排序 --- 卡片式UI设计
一、前言
上一篇我们完成了项目搭建和首页开发。本篇聚焦 GameListPage(游戏库页面)的开发,这是 App 的核心浏览页面。我们将实现:
- 多标签状态筛选(全部/在玩/通关/想玩)
- 循环排序切换(最近/名称/评分/时长)
- 游戏卡片 UI 设计
- 动态进度条
- 接收首页路由参数
二、页面功能概览
GameListPage 的功能架构如下:
┌─────────────────────────────────┐
│ ← 返回 📋 游戏库 🔍 搜索 │ ← Header
├─────────────────────────────────┤
│ [全部] [在玩] [通关] [想玩] │ ← 筛选标签(横向滚动)
├─────────────────────────────────┤
│ 排序: 最近 ▼ 共5款 │ ← 排序行
├─────────────────────────────────┤
│ ┌───────────────────────────┐ │
│ │ 🎮 ┌────────────────────┐ │ │
│ │ │ 艾尔登法环 49 │ │ │
│ │ │ PC · 动作RPG │ │ │
│ │ │ [通关] 186h │ │ │
│ │ │ ████████░░ 100% │ │ │
│ │ └────────────────────┘ │ │ ← 游戏卡片列表
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ 🎮 塞尔达传说... 48 │ │
│ │ Switch · 动作冒险 │ │
│ │ [在玩] 72h │ │
│ │ █████░░░░░ 55% │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
三、数据模型
为了更好地区分,我们在列表页定义独立的 GameItem 接口:
typescript
interface GameItem {
id: number;
title: string;
platform: string;
genre: string;
status: string; // 通关 / 在玩 / 想玩
rating: number; // 媒体评分(0-49)
hours: number; // 游玩时长
progress: number; // 进度百分比(0-100)
coverColor: string; // 封面颜色代码
}
四、核心实现
4.1 组件结构与状态定义
typescript
@Entry
@Component
struct GameListPage {
@State games: GameItem[] = [];
@State filter: string = '全部'; // 当前筛选状态
@State filters: string[] = ['全部', '在玩', '通关', '想玩'];
@State sortBy: string = '最近'; // 当前排序方式
aboutToAppear(): void {
// 接收首页传来的筛选参数
const params = router.getParams() as Record<string, Object>;
if (params && params['filter'] !== undefined) {
this.filter = params['filter'] as string;
}
this.games = [ /* 游戏数据表 */ ];
}
}
4.2 接收路由参数 ⭐
这是连接首页筛选标签和列表页的关键:
typescript
aboutToAppear(): void {
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
if (params && params['filter'] !== undefined) {
this.filter = params['filter'] as string;
}
this.games = [ /* ... */ ];
}
首页跳转代码(来自 Index.ets):
typescript
.onClick(() => {
router.pushUrl({
url: 'pages/GameListPage',
params: { filter: '通关' }
});
})
4.3 筛选与排序逻辑
筛选函数
typescript
getFilteredGames(): GameItem[] {
if (this.filter === '全部') {
return this.games;
}
return this.games.filter((g: GameItem) => g.status === this.filter);
}
排序循环切换
我们实现了一个循环切换的逻辑,每次点击切换到下一种排序方式:
typescript
@Builder buildSortRow() {
Row() {
Text('排序:').fontSize(12).fontColor('#999999')
Row() {
Text(this.sortBy).fontSize(12).fontColor('#FF6B35')
Text(' ▼').fontSize(10).fontColor('#FF6B35')
}
.padding({ left: 8, right: 10, top: 3, bottom: 3 })
.backgroundColor('#FFF0E8').borderRadius(10)
.margin({ left: 6 })
.onClick(() => {
const sorts: string[] = ['最近', '名称', '评分', '时长'];
const idx: number = sorts.indexOf(this.sortBy);
this.sortBy = sorts[(idx + 1) % sorts.length];
// TODO: 实际项目中在此处调用排序函数
})
Blank()
Text(`共${this.getFilteredGames().length}款`)
.fontSize(12).fontColor('#999999')
}
.width('100%').padding({ left: 16, right: 16, top: 8 })
}
设计思路 :采用索引取模
(idx + 1) % length的方式实现循环切换,比 if-else 链更简洁优雅。
4.4 筛选标签 --- 带状态的高亮
typescript
@Builder buildFilters() {
Scroll() {
Row() {
ForEach(this.filters, (f: string) => {
Text(f)
.fontSize(13)
.fontColor(this.filter === f ? '#FFFFFF' : '#666666')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(this.filter === f ? '#FF6B35' : '#F0F0F0')
.borderRadius(16).margin({ right: 8 })
.onClick(() => { this.filter = f; })
}, (f: string) => f)
}.padding({ left: 16 })
}
.scrollable(ScrollDirection.Horizontal).height(40)
}
交互细节:
- 选中标签:橙色背景 + 白色文字(
#FF6B35/#FFFFFF) - 未选中标签:浅灰背景 + 深灰文字(
#F0F0F0/#666666) - 点击即切换
this.filter,ArkTS 自动触发getFilteredGames()重渲染
4.5 游戏卡片 UI --- 装饰器模式
这是整个页面最核心的 UI 组件。我们使用 @Builder 将卡片封装为一个可复用的构建函数:
typescript
@Builder buildGameCard(game: GameItem) {
Row() {
// 左侧:封面色块 + emoji
Stack() {
Column()
.width(60).height(80).borderRadius(8)
.backgroundColor(game.coverColor)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Text('🎮').fontSize(26)
}.width(60).height(80)
// 右侧:文字信息
Column() {
// 第一行:标题 + 评分
Row() {
Text(game.title)
.fontSize(15).fontWeight(FontWeight.Medium)
.fontColor('#1A1A2E').layoutWeight(1)
if (game.rating > 0) {
Text(game.rating.toString())
.fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(game.rating >= 48 ? '#E74C3C' : '#F39C12')
}
}.width('100%')
// 第二行:平台 + 类型
Text(`${game.platform} · ${game.genre}`)
.fontSize(12).fontColor('#999999').margin({ top: 4 })
// 第三行:状态标签 + 时长
Row() {
Text(game.status)
.fontSize(11).fontColor(Color.White)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(
game.status === '通关' ? '#2ECC71' :
game.status === '在玩' ? '#3498DB' : '#9B59B6'
).borderRadius(6)
if (game.hours > 0) {
Text(`${game.hours}h`)
.fontSize(11).fontColor('#BBBBBB').margin({ left: 8 })
}
}.margin({ top: 4 })
// 进度条(仅进行中且未完成时显示)
if (game.progress > 0 && game.progress < 100) {
Progress({ value: game.progress, total: 100, style: ProgressStyle.Linear })
.width('80%').height(4).value(game.progress)
.color('#FF6B35').backgroundColor('#F0F0F0')
.borderRadius(2).margin({ top: 4 })
}
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 }).layoutWeight(1)
}
.width('100%').padding(12).backgroundColor('#FFFFFF')
.borderRadius(10).margin({ top: 8, left: 16, right: 16 })
.onClick(() => {
router.pushUrl({
url: 'pages/GameDetailPage',
params: { gameId: game.id }
})
})
}
4.6 卡片设计详解
(1) 封面色块
每个游戏分配一个独特的十六进制颜色代码,作为封面的替代方案:
| 游戏 | 色值 | 寓意 |
|---|---|---|
| 艾尔登法环 | #FFD700 |
黄金树的金色 |
| 塞尔达传说 | #2ECC71 |
海拉鲁的翠绿 |
| 博德之门3 | #E74C3C |
夺心魔的深红 |
| 赛博朋克2077 | #3498DB |
夜之城的霓虹蓝 |
这种方案避免了实际截图的资源占用,同时通过颜色传递游戏氛围。
(2) 评分颜色分级
typescript
game.rating >= 48 ? '#E74C3C' : '#F39C12'
- 评分 ≥ 48(满分 50):红色高亮,表示顶级神作
- 评分 < 48:橙色,表示优质作品
(3) 状态标签颜色编码
typescript
通关 → 绿色(#2ECC71) 🟢 已完成
在玩 → 蓝色(#3498DB) 🔵 进行中
想玩 → 紫色(#9B59B6) 🟣 待开始
(4) 条件渲染进度条
仅当 0 < progress < 100 时显示进度条。通关游戏(100%)不显示进度条,避免视觉冗余:
typescript
if (game.progress > 0 && game.progress < 100) {
Progress({ value: game.progress, total: 100, style: ProgressStyle.Linear })
.width('80%').height(4)
.color('#FF6B35').backgroundColor('#F0F0F0')
}
ArkTS 的 Progress 组件支持线性样式,自带动画效果。
4.7 组装页面
typescript
build(): void {
Column() {
this.buildHeader() // 固定头部
this.buildFilters() // 可滚动筛选标签
this.buildSortRow() // 排序行
Scroll() {
Column() {
ForEach(
this.getFilteredGames(),
(game: GameItem) => { this.buildGameCard(game) },
(game: GameItem) => game.id.toString() + this.filter
)
}.width('100%').padding({ bottom: 30 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').backgroundColor('#F5F5F5')
}
关于 key 的思考 :
在 ForEach 的 key 生成器中,我们使用了 game.id.toString() + this.filter。这样做的好处是:
- 筛选切换时,key 变化会触发列表完全重建,确保筛选后的数据正确渲染
- 避免仅用
game.id时,ArkTS 的 diff 机制保留已删除 DOM 节点的问题
五、进阶话题:ForEach 的复用策略
ArkTS 的 ForEach 基于 key 进行 diff 更新。理解 key 策略对性能至关重要:
| Key 策略 | 行为 | 适用场景 |
|---|---|---|
唯一且稳定 (如 id) |
尽量复用已有组件,只更新数据 | 数据不增减的静态列表 |
包含筛选条件 (如 id+filter) |
筛选变化时重建全部 | 筛选条件变化需要重新布局 |
索引 (index) |
紧耦合于位置,慎用! | 不推荐用于可排序列表 |
在我们的场景中,筛选切换需要卡片布局完全刷新,因此使用 id + filter 作为复合 key。
六、ArkTS 严格模式避坑
6.1 对象字面量类型声明
typescript
// ❌ 错误:arkts-no-untyped-obj-literals
Row() {
Text('通关').onClick(() => {})
}
// ✅ 正确:将对象字面量提取为类型变量
interface FilterItem { label: string; key: string; }
const filterItems: FilterItem[] = [
{ label: '全部', key: 'all' }
];
6.2 数组字面量类型推断
typescript
// ❌ 错误:arkts-no-noninferrable-arr-literals
const sorts = ['最近', '名称', '评分', '时长'];
// ✅ 正确:显式声明
const sorts: string[] = ['最近', '名称', '评分', '时长'];
6.3 Filter 回调类型标注
typescript
// ✅ 必须显式声明参数类型
this.games.filter((g: GameItem) => g.status === this.filter)
七、Header 的设计
typescript
@Builder buildHeader() {
Row() {
Text('←')
.fontSize(20).fontColor('#333333')
.onClick(() => { router.back(); })
Blank()
Text('📋 游戏库')
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
Blank()
Text('🔍').fontSize(18)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
}
左侧返回按钮调用 router.back() 回到首页;右侧搜索图标为后续功能预留。

八、小结
本篇我们完成了:
- ✅ 游戏库列表页完整开发
- ✅ 多标签筛选 + 循环排序切换
- ✅ 卡片式 UI 设计(封面色块、评分、进度条)
- ✅ 路由参数接收与 ForEach 复用策略
- ✅ ArkTS 严格模式常见问题解决
下一篇将深入 游戏详情页,实现全屏 Header、状态切换、星级评分、成就系统和用户评测等丰富交互功能。
系列目录:
- 第一篇:项目搭建与首页开发
- 第二篇:游戏库列表与筛选排序(本文)
- 第三篇:游戏详情页与交互功能
- 第四篇:愿望单与个人统计
- 第五篇:路由导航与工程优化