鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
一、前言
上一篇我们完成了项目搭建和首页开发。本篇聚焦GameListPage(游戏库页面)的开发,这是 App 的核心浏览页面。我们将实现:
- 多标签状态筛选(全部/在玩/通关/想玩)
- 循环排序切换(最近/名称/评分/时长)
- 游戏卡片 UI 设计
- 动态进度条
- 接收首页路由参数
二、页面功能概览
GameListPage 的功能架构如下:
┌─────────────────────────────────┐ │ ← 返回 📋 游戏库 🔍 搜索 │ ← Header ├─────────────────────────────────┤ │ [全部] [在玩] [通关] [想玩] │ ← 筛选标签(横向滚动) ├─────────────────────────────────┤ │ 排序: 最近 ▼ 共5款 │ ← 排序行 ├─────────────────────────────────┤ │ ┌───────────────────────────┐ │ │ │ 🎮 ┌────────────────────┐ │ │ │ │ │ 艾尔登法环 49 │ │ │ │ │ │ PC · 动作RPG │ │ │ │ │ │ [通关] 186h │ │ │ │ │ │ ████████░░ 100% │ │ │ │ │ └────────────────────┘ │ │ ← 游戏卡片列表 │ └───────────────────────────┘ │ │ ┌───────────────────────────┐ │ │ │ 🎮 塞尔达传说... 48 │ │ │ │ Switch · 动作冒险 │ │ │ │ [在玩] 72h │ │ │ │ █████░░░░░ 55% │ │ │ └───────────────────────────┘ │ └─────────────────────────────────┘三、数据模型
为了更好地区分,我们在列表页定义独立的GameItem接口:
interfaceGameItem{id:number;title:string;platform:string;genre:string;status:string;// 通关 / 在玩 / 想玩rating:number;// 媒体评分(0-49)hours:number;// 游玩时长progress:number;// 进度百分比(0-100)coverColor:string;// 封面颜色代码}四、核心实现
4.1 组件结构与状态定义
@Entry@Componentstruct GameListPage{@Stategames:GameItem[]=[];@Statefilter:string='全部';// 当前筛选状态@Statefilters:string[]=['全部','在玩','通关','想玩'];@StatesortBy:string='最近';// 当前排序方式aboutToAppear():void{// 接收首页传来的筛选参数constparams=router.getParams()asRecord<string,Object>;if(params&¶ms['filter']!==undefined){this.filter=params['filter']asstring;}this.games=[/* 游戏数据表 */];}}4.2 接收路由参数 ⭐
这是连接首页筛选标签和列表页的关键:
aboutToAppear():void{constparams:Record<string,Object>=router.getParams()asRecord<string,Object>;if(params&¶ms['filter']!==undefined){this.filter=params['filter']asstring;}this.games=[/* ... */];}首页跳转代码(来自 Index.ets):
.onClick(()=>{router.pushUrl({url:'pages/GameListPage',params:{filter:'通关'}});})4.3 筛选与排序逻辑
筛选函数
getFilteredGames():GameItem[]{if(this.filter==='全部'){returnthis.games;}returnthis.games.filter((g:GameItem)=>g.status===this.filter);}排序循环切换
我们实现了一个循环切换的逻辑,每次点击切换到下一种排序方式:
@BuilderbuildSortRow(){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(()=>{constsorts:string[]=['最近','名称','评分','时长'];constidx: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 筛选标签 — 带状态的高亮
@BuilderbuildFilters(){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将卡片封装为一个可复用的构建函数:
@BuilderbuildGameCard(game:GameItem){Row(){// 左侧:封面色块 + emojiStack(){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) 评分颜色分级
game.rating>=48?'#E74C3C':'#F39C12'- 评分 ≥ 48(满分 50):红色高亮,表示顶级神作
- 评分 < 48:橙色,表示优质作品
(3) 状态标签颜色编码
通关 →绿色(#2ECC71)🟢 已完成 在玩 →蓝色(#3498DB)🔵 进行中 想玩 →紫色(#9B59B6)🟣 待开始(4) 条件渲染进度条
仅当0 < progress < 100时显示进度条。通关游戏(100%)不显示进度条,避免视觉冗余:
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 组装页面
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 对象字面量类型声明
// ❌ 错误:arkts-no-untyped-obj-literalsRow(){Text('通关').onClick(()=>{})}// ✅ 正确:将对象字面量提取为类型变量interfaceFilterItem{label:string;key:string;}constfilterItems:FilterItem[]=[{label:'全部',key:'all'}];6.2 数组字面量类型推断
// ❌ 错误:arkts-no-noninferrable-arr-literalsconstsorts=['最近','名称','评分','时长'];// ✅ 正确:显式声明constsorts:string[]=['最近','名称','评分','时长'];6.3 Filter 回调类型标注
// ✅ 必须显式声明参数类型this.games.filter((g:GameItem)=>g.status===this.filter)七、Header 的设计
@BuilderbuildHeader(){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、状态切换、星级评分、成就系统和用户评测等丰富交互功能。
系列目录:
- 第一篇:项目搭建与首页开发
- 第二篇:游戏库列表与筛选排序(本文)
- 第三篇:游戏详情页与交互功能
- 第四篇:愿望单与个人统计
- 第五篇:路由导航与工程优化
