鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计

鸿蒙原生应用实战(二):游戏库列表与筛选排序 --- 卡片式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() 回到首页;右侧搜索图标为后续功能预留。


八、小结

本篇我们完成了:

  1. ✅ 游戏库列表页完整开发
  2. ✅ 多标签筛选 + 循环排序切换
  3. ✅ 卡片式 UI 设计(封面色块、评分、进度条)
  4. ✅ 路由参数接收与 ForEach 复用策略
  5. ✅ ArkTS 严格模式常见问题解决

下一篇将深入 游戏详情页,实现全屏 Header、状态切换、星级评分、成就系统和用户评测等丰富交互功能。


系列目录

  • 第一篇:项目搭建与首页开发
  • 第二篇:游戏库列表与筛选排序(本文)
  • 第三篇:游戏详情页与交互功能
  • 第四篇:愿望单与个人统计
  • 第五篇:路由导航与工程优化
相关推荐
Davina_yu21 小时前
定时器与任务调度:setTimeout与setInterval的正确使用(19)
harmonyos·鸿蒙·鸿蒙系统
祭曦念1 天前
【共创季稿事节】鸿蒙原生ArkTS布局深度解析_GridRow_Row_Column混合栅格布局实战
华为·harmonyos
kiros_wang1 天前
鸿蒙 ArkUI:V1 与 V2 装饰器全面对比与迁移指南
ubuntu·华为·harmonyos
古德new1 天前
鸿蒙PC迁移:Photoflare Qt 图片编辑器鸿蒙PC适配全记录
qt·编辑器·harmonyos
不羁的木木1 天前
HarmonyOS 6.1.0 创新特性技术精讲之沉浸光感
华为·harmonyos
JOJO数据科学1 天前
JupyterLab Electron 鸿蒙 PC 适配全记录:从 Python 原生崩溃到 node-static 本地工作台
python·electron·harmonyos
CHB1 天前
HDC2026 演讲实录|AI 驱动的跨端进化:利用 uni-agent 快速构建高性能鸿蒙应用
uni-app·harmonyos
祭曦念1 天前
【共创季稿事节】鸿蒙ArkTS布局实战_Column交叉轴对齐
华为·harmonyos
古德new1 天前
鸿蒙PC迁移:Anki Qt 记忆卡片工具鸿蒙PC适配全记录
qt·华为·harmonyos
TMT星球1 天前
创梦天地《地铁跑酷》携手鸿蒙 深化全场景生态共建
华为·harmonyos