【鸿蒙原生应用开发实战】第三篇:列表页与标签筛选功能 — 打造高效的天体列表

【鸿蒙原生应用开发实战】第三篇:列表页与标签筛选功能 --- 打造高效的天体列表

前言

列表页是移动App中最常见也最重要的页面类型之一。在"宇宙探索"App中,CelestialPage(天体列表页)承担着展示天体和分类筛选的核心职责。

本篇我们将深入实现:

  • 5个分类标签的切换筛选
  • 天体卡片组件的完整设计
  • 收藏交互的即时反馈
  • 列表渲染性能优化要点

一、页面功能总览

CelestialPage 实现了三个核心功能:

  1. 标签导航 --- 顶部5个标签(全部/行星/恒星/星系/星云),点击切换筛选
  2. 列表展示 --- 按标签筛选结果展示天体卡片
  3. 收藏交互 --- 每个卡片可以直接收藏/取消收藏

页面结构

复制代码
┌──────────────────────────────┐
│ ← 天体列表                    │  ← 顶部导航栏
├──────────────────────────────┤
│ [全部] [行星] [恒星] [星系] [星云]│  ← 标签栏
├──────────────────────────────┤
│ ██ 太阳                      │  ← 天体卡片
│    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 下,filtermap 等方法的类型推断可能会出问题。使用传统的 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()

场景重现

  1. 用户进入列表页,收藏了"地球"(☆ → ★)
  2. 点击"地球"卡片跳转到详情页
  3. 在详情页取消收藏"地球"(★ → ☆)
  4. 点击返回,回到列表页
  5. 此时列表页需要通过 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) => { ... })

七、完整页面效果

当用户从首页点击"行星"分类:

  1. CelestialPage 启动,接收 filterType = '行星'
  2. 标签栏高亮"行星"标签(金色)
  3. applyFilter() 筛选出6个行星:水星、金星、地球、火星、木星、土星
  4. 列表中展示6个天体卡片,每个左侧有不同颜色的标识条
  5. 用户可以点击☆收藏任意天体
  6. 点击卡片跳转到详情页

八、本篇总结

本篇我们完成了 CelestialPage 的完整开发,核心收获:

  1. 标签筛选机制 --- activeTab + applyFilter() 实现分类切换
  2. CelestialCard 组件 --- 颜色标识条、信息展示、收藏按钮的完整设计
  3. 点击事件隔离 --- 收藏按钮和卡片点击各自独立
  4. 页面生命周期 --- aboutToAppear vs onPageShow 的区别与使用场景
  5. 路由参数传递 --- 从首页分类入口携带筛选参数跳转
  6. 数据驱动UI --- @State isFav 变化自动刷新收藏图标

下篇预告 :我们将开发最丰富的页面 --- DetailPage(天体详情页),包含动态数据切换、四个信息维度展示、趣味知识模块,以及收藏按钮的完整交互。


本篇涉及的文件

  • entry/src/main/ets/pages/CelestialPage.ets --- 列表页与卡片组件
  • entry/src/main/ets/model/CelestialData.ets --- 数据源
相关推荐
无限码力1 小时前
华为非AI方向笔试真题 - 容器镜像平均大小统计
算法·华为·华为非ai方向笔试真题·华为笔试真题·华为非ai笔试真题·华为0612非ai笔试真题
Keep_Trying_Go1 小时前
华为开源框架MindSpore基本使用
华为·开源
无限码力1 小时前
华为非AI方向0612笔试真题-循环异或加密器(详细思路+多语言题解)
算法·华为·华为非ai方向笔试真题·华为笔试真题·华为0612笔试真题
烛衔溟2 小时前
HarmonyOS 工程目录、配置文件与 Stage 模型核心
华为·harmonyos
祭曦念2 小时前
【共创季稿事节】鸿蒙原生 ArkTS 布局:NavRouter + NavDestination 导航布局实战
ubuntu·华为·harmonyos
木咺吟11 小时前
鸿蒙原生应用实战(一):从零搭建快递追踪App——项目初始化与工程架构详解
华为·harmonyos
坚果派·白晓明14 小时前
【鸿蒙PC】SDL3 移植:AtomCode Skills 4 步速通多媒体库适配
c++·华为·ai编程·harmonyos·atomcode·c/c++三方库
风满城3315 小时前
鸿蒙原生应用实战(三):设置与统计页面开发 — 数据驱动的功能模块
harmonyos
xcLeigh15 小时前
鸿蒙平台 KeePass 密码管理器适配实战:从 Windows 到 鸿蒙PC 的 Electron 迁移指南
windows·electron·web·harmonyos·加密算法·keepass