鸿蒙原生应用实战(三):搜索与详情页 —— 多维度筛选与动态路由
鸿蒙原生应用实战(三):搜索与详情页 —— 多维度筛选与动态路由
前言
上一篇我们完成了首页的开发。本篇将进入两个功能型页面的实现——搜索页(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;
}
算法设计要点:
- 链式过滤:从一个完整数据集开始,逐层过滤
- 分类用
indexOf模糊匹配:"古装仙侠".indexOf("古装") >= 0返回true,这样用户选择"古装"分类时,所有古装子类(古装仙侠、古装爱情、古装武侠)都能匹配到 - 状态用
===精确匹配:状态值只有"连载中"和"已完结"两种,需要精确匹配 - 空关键词跳过:
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();
}
}
路由参数接收三要素:
router.getParams()获取所有参数as Record<string, Object>类型断言(ArkTS严格模式必需)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 不能直接访问属性,必须:
- 断言为
Record<string, Object> - 取值后再
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实现虚拟列表 - 或仅渲染可视区域内的集数

五、篇末总结
本篇我们完成了搜索页和详情页的开发,核心内容包括:
- ✅ 多维度组合筛选算法(关键词 + 分类 + 状态)
- ✅ TextInput实时搜索 + 清除按钮交互
- ✅ 自定义单选按钮的模拟实现
- ✅ 路由参数
router.getParams()的类型安全获取 - ✅ 动态数据加载与ID匹配策略
- ✅ @Builder组件化构建复杂页面
- ✅ Tab切换的多内容区域渲染
- ✅ 单集已看/未看切换逻辑
下一篇将实现我的追剧页与统计页,深入讲解:
- 三态Tab管理及筛选
- 空状态设计与引导
- 数据可视化条状图
- 成就徽章系统
文章索引:
- (一)项目初始化与Stage模型架构设计
- (二)首页开发 —— 周历导航与@Builder组件化实践
- (三)搜索与详情页 —— 多维度筛选与动态路由 ← 当前
- (四)我的追剧与统计页 —— 三态Tab与数据可视化
- (五)编译构建与性能优化 —— 从开发到上架
更多推荐


所有评论(0)