鸿蒙原生应用实战(三):搜索与详情页 —— 多维度筛选与动态路由

前言

上一篇我们完成了首页的开发。本篇将进入两个功能型页面的实现——搜索页(SearchPage)详情页(DetailPage)

搜索页需要支持多维度的筛选能力,详情页则需要根据路由参数动态加载不同剧集的数据。这两页共同展示了ArkTS在数据操作和状态管理方面的核心能力。


一、搜索页(SearchPage)完整实现

1.1 页面功能分析

SearchPage
├── 搜索栏:关键词实时搜索 + 清除按钮
├── 分类筛选:横向滚动的标签(全部/古装/现代/悬疑/爱情/科幻/青春/年代)
├── 状态筛选:单选框(全部/连载中/已完结)
└── 搜索结果列表:筛选后的剧集卡片

1.2 数据结构

搜索页使用的Drama接口比首页更丰富:

interface Drama {
  id: number;
  title: string;
  cover: string;
  genre: string;           // 类型(如"古装仙侠""犯罪悬疑")
  episodes: number;
  watchedEpisodes: number;
  status: string;          // "连载中"/"已完结"
  rating: number;
  updateDay: string;
  isNew: boolean;
  year: number;            // 年份(搜索页新增)
  director: string;        // 导演(搜索页新增)
  actors: string;          // 主演(搜索页新增)
}

1.3 状态变量设计

@Component
struct SearchPage {
  @State searchKeyword: string = '';           // 搜索关键词
  @State selectedGenre: string = '全部';        // 选中的分类
  @State selectedStatus: string = '全部';        // 选中的状态
  @State allDramas: Drama[] = [];               // 全量数据(不变)
  @State filteredDramas: Drama[] = [];           // 筛选后数据(驱动UI)
  @State genres: string[] = ['全部', '古装', '现代', '悬疑', '爱情', '科幻', '青春', '年代'];
  @State statusFilters: string[] = ['全部', '连载中', '已完结'];
}

设计原则

  • 保留 allDramas 原始数据,每次筛选从全量数据重新计算
  • filteredDramas 是UI渲染的直接数据源
  • 筛选条件独立存储,便于分别修改

1.4 数据初始化

aboutToAppear(): void {
  this.initAllDramas();
  this.filterDramas();
}

initAllDramas(): void {
  this.allDramas = [
    { id: 1, title: '星落凝成糖', genre: '古装仙侠', episodes: 40, status: '连载中', rating: 47, year: 2025, director: '朱锐斌', actors: '陈星旭,李兰迪', /* ... */ },
    { id: 2, title: '长风渡', genre: '古装爱情', episodes: 38, status: '连载中', rating: 45, year: 2025, director: '尹涛', actors: '白敬亭,宋轶', /* ... */ },
    // ... 共16条数据,覆盖8种类型,覆盖连载中和已完结
  ];
  this.filterDramas();
}

包含16条模拟数据,涵盖了多种类型和状态,足够展示完整的筛选效果。

1.5 核心:多维度组合筛选算法

filterDramas(): void {
  let result: Drama[] = this.allDramas;

  // 维度一:关键词搜索(包含匹配)
  if (this.searchKeyword.length > 0) {
    result = result.filter((item: Drama) =>
      item.title.indexOf(this.searchKeyword) >= 0
    );
  }

  // 维度二:分类筛选(模糊匹配)
  if (this.selectedGenre !== '全部') {
    result = result.filter((item: Drama) =>
      item.genre.indexOf(this.selectedGenre) >= 0  // "古装仙侠"包含"古装"
    );
  }

  // 维度三:状态筛选(精确匹配)
  if (this.selectedStatus !== '全部') {
    result = result.filter((item: Drama) =>
      item.status === this.selectedStatus
    );
  }

  this.filteredDramas = result;
}

算法设计要点

  1. 链式过滤:从一个完整数据集开始,逐层过滤
  2. 分类用 indexOf 模糊匹配"古装仙侠".indexOf("古装") >= 0 返回true,这样用户选择"古装"分类时,所有古装子类(古装仙侠、古装爱情、古装武侠)都能匹配到
  3. 状态用 === 精确匹配:状态值只有"连载中"和"已完结"两种,需要精确匹配
  4. 空关键词跳过this.searchKeyword.length > 0 确保空字符串时不执行搜索过滤

1.6 搜索栏构建

@Builder buildSearchHeader() {
  Row() {
    // 返回按钮
    Text('←').fontSize(20).fontColor('#333333')
      .onClick(() => { router.back(); })

    // 搜索输入框
    Stack() {
      TextInput({ placeholder: '搜索剧集名称...' })
        .width('85%').height(36).backgroundColor('#F0F0F0')
        .borderRadius(18).padding({ left: 16 }).fontSize(14)
        .placeholderColor('#999999')
        .onChange((value: string) => { this.onKeywordChange(value); })

      // 清除按钮(条件渲染)
      if (this.searchKeyword.length > 0) {
        Text('✕').fontSize(14).fontColor('#999999')
          .position({ right: 16 })
          .onClick(() => {
            this.searchKeyword = '';
            this.filterDramas();
          })
      }
    }
    .layoutWeight(1).margin({ left: 12 })
  }
  .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
  .backgroundColor('#FFFFFF')
}

onKeywordChange(value: string): void {
  this.searchKeyword = value;
  this.filterDramas();  // 每次输入变化实时触发筛选
}

TextInput + onChage 实现实时搜索

  • 用户每输入一个字符都会触发 onChange 回调
  • onKeywordChange 更新关键词并立即执行 filterDramas()
  • 无"搜索按钮",输入即搜索,体验更流畅

清除按钮交互细节

  • 利用 if 条件渲染,仅在有关键词时显示
  • position({ right: 16 }) 在Stack中定位到输入框右内侧
  • 点击清除后同时清空关键词和触发筛选

1.7 分类筛选器

@Builder buildFilters() {
  Column() {
    // 横向滚动分类标签
    Scroll() {
      Row() {
        ForEach(this.genres, (genre: string) => {
          Text(genre)
            .fontSize(12)
            .fontColor(this.selectedGenre === genre ? '#FFFFFF' : '#666666')
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(this.selectedGenre === genre ? '#FF6B35' : '#F0F0F0')
            .borderRadius(14).margin({ right: 8 })
            .onClick(() => {
              this.selectedGenre = genre;
              this.filterDramas();
            })
        }, (genre: string) => genre)
      }.padding({ left: 16, right: 16, top: 8 })
    }
    .scrollable(ScrollDirection.Horizontal)  // 分类过多时可滑动
    .height(36)

    // 状态单选按钮
    Row() {
      ForEach(this.statusFilters, (status: string) => {
        Row() {
          // 自定义单选按钮样式
          Stack() {
            if (this.selectedStatus === status) {
              Column().width(14).height(14).borderRadius(7)
                .backgroundColor('#FF6B35')
                .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
              Text('✓').fontSize(10).fontColor(Color.White)
            } else {
              Column().width(14).height(14).borderRadius(7)
                .border({ width: 1.5, color: '#CCCCCC' })
            }
          }
          Text(status).fontSize(13).fontColor('#333333').margin({ left: 6 })
        }
        .margin({ right: 20 })
        .onClick(() => {
          this.selectedStatus = status;
          this.filterDramas();
        })
      }, (status: string) => status)
    }
    .padding({ left: 16, right: 16, top: 8 })
  }
  .width('100%').backgroundColor('#FFFFFF').padding({ bottom: 8 })
}

自定义单选按钮实现

ArkTS没有直接的单选按钮(RadioButton)组件,我们用条件渲染模拟:

选中态: 橙色圆形背景 + 白色对勾
未选中态: 白色圆形 + 灰色边框

这种自定义方案的优点:

  • 视觉风格与应用主色调一致
  • 无需引入额外组件
  • 完全控制样式细节

1.8 结果列表

build(): void {
  Column() {
    this.buildSearchHeader()
    this.buildFilters()

    Text(`找到 ${this.filteredDramas.length} 部剧集`)
      .fontSize(12).fontColor('#999999').width('100%')
      .padding({ left: 16, top: 12 })

    Scroll() {
      Column() {
        ForEach(this.filteredDramas, (item: Drama) => {
          this.buildDramaCard(item)
        }, (item: Drama) => item.id.toString())
      }
      .width('100%').padding({ left: 16, right: 16, bottom: 20 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1).width('100%')
  }
  .width('100%').height('100%').backgroundColor('#F5F5F5')
}

搜索结果卡片展示的信息比首页卡片更丰富:

🎬 [封面占位]
标题                 评分
类型 | 年份 | 导演
[连载中]标签   24集
主演: 某某某, 某某某

点击卡片跳转到详情页,传递 dramaId 参数。


二、详情页(DetailPage)完整实现

2.1 页面功能分析

DetailPage
├── 头部区域:返回按钮 + 收藏按钮 + 剧集标题 + 类型年份
├── 评分栏:综合评分 / 已看集数 / 完成进度
├── 信息栏:导演 / 主演 / 状态
├── Tab切换:分集列表 | 剧情简介 | 演员阵容
└── 内容区:根据Tab显示对应内容

2.2 数据结构

interface Episode {
  num: number;         // 集号(第几集)
  title: string;       // 单集标题
  watched: boolean;    // 是否已看
  duration: string;    // 时长(如"45分钟")
}

interface DramaDetail {
  id: number;
  title: string;
  genre: string;
  episodes: number;     // 总集数
  status: string;
  rating: number;
  year: number;
  director: string;
  actors: string;
  description: string;         // 剧情简介
  episodesList: Episode[];     // 分集列表
}

2.3 路由参数获取(核心知识点)

@Component
struct DetailPage {
  @State dramaId: number = -1;       // 从路由获取的剧集ID
  @State detail: DramaDetail | null = null;  // 剧集详情(可能为null)
  @State isCollected: boolean = false;
  @State currentTab: number = 0;     // 当前选中的Tab索引
  @State watchedCount: number = 0;   // 已看集数
  tabs: string[] = ['分集列表', '剧集简介', '演员阵容'];

  aboutToAppear(): void {
    // ★ 关键:从路由参数中获取 dramaId
    const params: Record<string, Object> = router.getParams() as Record<string, Object>;
    if (params && params['dramaId'] !== undefined) {
      this.dramaId = params['dramaId'] as number;
    }
    this.loadDetail();
  }
}

路由参数接收三要素

  1. router.getParams() 获取所有参数
  2. as Record<string, Object> 类型断言(ArkTS严格模式必需)
  3. params['dramaId'] as number 取值并转型

2.4 动态数据加载

loadDetail(): void {
  const allDetails: DramaDetail[] = [
    {
      id: 1, title: '星落凝成糖', genre: '古装仙侠', episodes: 40,
      status: '连载中', rating: 47, year: 2025, director: '朱锐斌',
      actors: '陈星旭,李兰迪',
      description: '该剧讲述了一对孪生公主在机缘巧合下被错嫁...',
      episodesList: this.generateEpisodes(40)
    },
    { id: 9, title: '狂飙', ... },
    { id: 10, title: '三体', ... },
    { id: 11, title: '漫长的季节', ... },
    // ... 共6条详细数据
  ];

  // 根据 dramaId 查找匹配的详情
  for (let i: number = 0; i < allDetails.length; i++) {
    if (allDetails[i].id === this.dramaId) {
      this.detail = allDetails[i];
      break;
    }
  }

  // 兜底:未找到时显示第一条
  if (!this.detail) {
    this.detail = allDetails[0];
  }

  this.updateWatchedCount();
}

数据加载策略

  • 从路由参数 dramaId 匹配对应的 DramaDetail 数据
  • 支持8个不同剧集的详情切换(首页4个 + 热门4个 + 搜索页列表中的其他剧集)
  • 未找到匹配时兜底显示第一条,确保页面不白屏
  • 加载后调用 updateWatchedCount() 同步计数

2.5 分集数据生成

generateEpisodes(count: number): Episode[] {
  const eps: Episode[] = [];
  const titles: string[] = ['初遇', '风波', '抉择', '危机', '转机', '重逢', '真相',
    '考验', '蜕变', '终章', '迷雾', '反击', '约定', '别离', '追寻', '守护', '破局',
    '曙光', '代价', '新生', '暗流', '交锋', '迷途', '觉醒', '博弈', '救赎', '深渊',
    '希望', '归途', '永恒', '涟漪', '风暴', '执念', '释怀', '起航', '渡劫', '执手',
    '圆满', '传承', '轮回'];

  for (let i: number = 1; i <= count; i++) {
    eps.push({
      num: i,
      title: i <= titles.length ? titles[i - 1] : `${i}`,
      watched: i <= 5,       // 默认前5集已看
      duration: '45分钟'
    });
  }
  return eps;
}

生成策略

  • 40个预设标题覆盖多数剧集(40集以内)
  • 超过40集自动使用"第N章"作为标题
  • 默认前5集标记为已看(模拟用户已追到第5集)
  • 实际项目中应替换为从服务器获取真实分集数据

2.6 已看逻辑

updateWatchedCount(): void {
  if (!this.detail) return;
  let count: number = 0;
  for (let i: number = 0; i < this.detail.episodesList.length; i++) {
    if (this.detail.episodesList[i].watched) {
      count++;
    }
  }
  this.watchedCount = count;
}

toggleWatched(epNum: number): void {
  if (!this.detail) return;
  for (let i: number = 0; i < this.detail.episodesList.length; i++) {
    if (this.detail.episodesList[i].num === epNum) {
      this.detail.episodesList[i].watched = !this.detail.episodesList[i].watched;
      break;
    }
  }
  this.updateWatchedCount();
}

toggle机制

  • 点击某集切换其 watched 状态
  • 每次切换后重新统计已看集数
  • 已看集数变化驱动UI实时更新

2.7 头部区域

@Builder buildHeader() {
  Stack() {
    // 背景装饰
    Column().width('100%').height(200).backgroundColor('#2C3E50')

    Column() {
      // 顶栏:返回 + 收藏
      Row() {
        Text('←').fontSize(22).fontColor(Color.White)
          .onClick(() => { router.back(); })
        Blank()
        Text('收藏').fontSize(14).fontColor(Color.White)
          .onClick(() => { this.toggleCollect(); })
      }
      .width('100%').padding({ left: 16, right: 16 })
      .position({ top: 40 })                // 从顶部偏移40

      // 居中内容:图标 + 标题 + 副信息
      Column() {
        Text('🎬').fontSize(48)
        Text(this.detail ? this.detail.title : '').fontSize(22)
          .fontWeight(FontWeight.Bold).fontColor(Color.White).margin({ top: 8 })
        Text(this.detail ? `${this.detail.genre} | ${this.detail.year}` : '')
          .fontSize(13).fontColor('#CCCCCC').margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Center).width('100%')
      .position({ top: 80 })               // 从顶部偏移80
    }
    .width('100%').height('100%')
  }
  .width('100%').height(200)
}

布局层次

Stack (200px高)
  ├── 背景 Column (#2C3E50深色背景)
  └── 内容 Column
        ├── 顶栏 (position: top=40) —— 返回 + 收藏
        └── 居中信息 (position: top=80) —— 图标 + 标题 + 类型/年份

position偏移:在Stack中使用 position({ top: xxx }) 进行绝对定位,精确控制元素位置。这是实现自定义头部布局的常用手段。

2.8 评分栏

@Builder buildRatingBar() {
  if (this.detail) {
    Row() {
      Column() {
        Text(this.detail.rating.toString()).fontSize(24)
          .fontWeight(FontWeight.Bold).fontColor('#FF6B35')
        Text('综合评分').fontSize(11).fontColor('#999999').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text(`${this.watchedCount}/${this.detail.episodes}`).fontSize(24)
          .fontWeight(FontWeight.Bold).fontColor('#3498DB')
        Text('已看/总集数').fontSize(11).fontColor('#999999').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text(`${this.getProgressPercent()}%`).fontSize(24)
          .fontWeight(FontWeight.Bold).fontColor('#2ECC71')
        Text('完成进度').fontSize(11).fontColor('#999999').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
    }
    .width('100%').padding(16).backgroundColor('#FFFFFF').margin({ top: 8 })
  }
}

三个指标列使用 layoutWeight(1) 等分宽度,每个指标包含一个大号数值和一个灰色标签说明。

2.9 Tab切换

@Builder buildTabs() {
  Row() {
    ForEach(this.tabs, (tab: string, index?: number) => {
      Column() {
        Text(tab).fontSize(14)
          .fontColor(this.currentTab === (index as number) ? '#FF6B35' : '#999999')
        // 下划线指示器
        Column().width('80%').height(2)
          .backgroundColor(this.currentTab === (index as number) ? '#FF6B35' : 'transparent')
          .borderRadius(1).margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
      .onClick(() => { this.currentTab = index as number; })
    }, (tab: string) => tab)
  }
  .width('100%').padding({ top: 12 }).backgroundColor('#FFFFFF').margin({ top: 8 })
}

Tab切换核心

  • currentTab 控制当前选中的Tab索引
  • 每个Tab点击时更新 currentTab
  • 选中Tab显示橙色文字 + 橙色下划线
  • 未选中显示灰色文字 + 透明下划线

内容区根据Tab渲染

if (this.currentTab === 0) {
  this.buildEpisodeList()     // 分集列表
} else if (this.currentTab === 1) {
  this.buildDescription()     // 剧情简介
} else {
  this.buildCast()            // 演员阵容
}

2.10 分集列表

@Builder buildEpisodeList() {
  if (this.detail) {
    Column() {
      ForEach(this.detail.episodesList, (ep: Episode) => {
        Row() {
          // 集数圆圈(已看橙色/未看灰色)
          Stack() {
            Column().width(28).height(28).borderRadius(14)
              .backgroundColor(ep.watched ? '#FF6B35' : '#F0F0F0')
            Text(ep.num.toString()).fontSize(12)
              .fontColor(ep.watched ? Color.White : '#999999')
          }
          // 集名 + 时长
          Column() {
            Text(ep.title).fontSize(14).fontColor('#333333')
            Text(`${ep.num}集 · ${ep.duration}`).fontSize(11)
              .fontColor('#BBBBBB').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
          // 状态标签
          Text(ep.watched ? '✓ 已看' : '未看').fontSize(11)
            .fontColor(ep.watched ? '#FF6B35' : '#CCCCCC')
        }
        .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FFFFFF')
        .onClick(() => { this.toggleWatched(ep.num); })
      }, (ep: Episode) => ep.num.toString())
    }.width('100%')
  }
}

每集卡片的交互

  • 点击切换已看/未看状态
  • 已看:橙色圆圈 + 白色对勾文字 + 橙色"✓ 已看"标签
  • 未看:灰色圆圈 + 灰色"未看"标签
  • 状态变化通过 toggleWatched() 实时更新

2.11 演员阵容

getCastList(): string[][] {
  if (!this.detail) return [];
  return [
    ['🎭', this.detail.actors.split(',')[0] || '', '领衔主演'],
    ['🎭', this.detail.actors.split(',')[1] || '', '领衔主演'],
    ['🎭', '特邀演员', '特别出演'],
    ['🎭', '友情出演', '友情出演']
  ];
}

this.detail.actors 字段(如 “陈星旭,李兰迪”)中解析主演名单,配合占位数据展示4行演员信息。


三、ArkTS严格模式下的类型处理

3.1 router.getParams() 返回值的类型断言

// 从router获取参数时,必须显式类型断言
const params: Record<string, Object> = router.getParams() as Record<string, Object>;

// 取值后转型
const dramaId: number = params['dramaId'] as number;

ArkTS严格模式下,router.getParams() 返回的 Object 不能直接访问属性,必须:

  1. 断言为 Record<string, Object>
  2. 取值后再 as number / as string

3.2 null安全检查

@State detail: DramaDetail | null = null;

// 使用前判空
if (this.detail) {
  // 安全访问 this.detail.title
}

在Builder方法中频繁判空可能冗余,但这是类型安全的基本要求。可以用 if (this.detail) { ... } 包裹整个Builder的内容区。

3.3 optional chaining的替代

ArkTS不支持可选链(?.)操作符,判空需要显式写法:

// ❌ 不支持的语法
this.detail?.title

// ✅ 支持的写法
this.detail ? this.detail.title : ''

四、性能优化提示

4.1 分集列表的数据变更

toggleWatched() 方法直接修改了 this.detail.episodesList[i].watched。由于 this.detail@State 变量,其内部属性的修改能否触发UI刷新?

答案是:可以。 @State的深度监听机制会追踪对象内部属性的变化。但是需要注意:

  • 只有第一层 @State 变量变化会触发刷新
  • this.detail.episodesList[i].watched = true 这种深层修改能触发UI刷新
  • 但如果使用数组方法(push/splice),则需要重新赋值

4.2 数据量级考虑

本项目的分集列表最多40集,使用 ForEach 直接渲染没有问题。如果剧集超过100集,建议:

  • 使用 LazyForEach 实现虚拟列表
  • 或仅渲染可视区域内的集数

在这里插入图片描述

五、篇末总结

本篇我们完成了搜索页和详情页的开发,核心内容包括:

  1. ✅ 多维度组合筛选算法(关键词 + 分类 + 状态)
  2. ✅ TextInput实时搜索 + 清除按钮交互
  3. ✅ 自定义单选按钮的模拟实现
  4. ✅ 路由参数 router.getParams() 的类型安全获取
  5. ✅ 动态数据加载与ID匹配策略
  6. ✅ @Builder组件化构建复杂页面
  7. ✅ Tab切换的多内容区域渲染
  8. ✅ 单集已看/未看切换逻辑

下一篇将实现我的追剧页与统计页,深入讲解:

  • 三态Tab管理及筛选
  • 空状态设计与引导
  • 数据可视化条状图
  • 成就徽章系统

文章索引:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐