HarmonyOS实战《疆域纪行》第04篇|探索页检索链路:关键词、地区筛选和空状态联动
这一篇讲一个很实用的链路:不接后端,如何在本地内容库上完成搜索、类型切换、地区筛选、主题筛选、排序和无结果提示。探索页是内容 App 的“找东西”能力,写顺了,用户才不会迷路。
项目落点:
- 搜索状态:
searchText - 类型状态:
exploreType - 地区状态:
exploreRegion - 主题状态:
exploreTheme - 搜索历史:
searchHistory - 结果计算:
getExploreAtlasItems()、getExploreRouteItems()、getExploreCities()
读完这一篇,你可以复用一套本地检索链路:输入更新状态、提交写入历史、多条件过滤列表、无结果时给用户下一步提示。
探索页解决什么问题
首页负责推荐,探索页负责查找。
旅行攻略类 App 的内容一多,用户就会提出几个很自然的问题:
- 我只想看北疆内容。
- 我只想看自然风光。
- 我想搜“喀什”。
- 我想把景点、美食、路线分开看。
- 没有结果时能不能给我一点提示。
《疆域纪行》的探索页就是围绕这些问题设计的。它不依赖后端搜索,而是直接在本地静态内容库上做过滤。
探索页状态设计
相关状态在 Index.ets 中:
@State searchText: string = '';
@State exploreType: string = 'spot';
@State exploreRegion: string = 'all';
@State exploreTheme: string = 'all';
@State exploreSort: string = 'recommend';
@State searchHistory: string[] = [];
这几个状态对应探索页的五个维度:
searchText:关键词exploreType:景点 / 美食 / 路线 / 城市exploreRegion:全部 / 北疆 / 南疆 / 连接地带exploreTheme:自然风光 / 人文古城 / 美食小吃exploreSort:推荐排序 / 短时优先
状态拆得足够细,页面逻辑就可以很清楚:任何筛选项变化,只要更新对应状态,列表就会重新计算。
探索页 UI 结构
ExplorePage() 的结构如下:
@Builder
ExplorePage() {
Column({ space: 16 }) {
this.PageHeader('探索', 'Explore')
this.ExploreSearchBar()
this.ExploreSearchHistory()
Row({ space: 8 }) {
this.ExploreTypeChip('spot', '景点')
this.ExploreTypeChip('food', '美食')
this.ExploreTypeChip('route', '路线')
this.ExploreTypeChip('city', '城市')
}
Scroll() {
Row({ space: 8 }) {
this.ExploreFilterChip('all', '全部地区', 'region')
this.ExploreFilterChip('north', '北疆', 'region')
this.ExploreFilterChip('south', '南疆', 'region')
this.ExploreFilterChip('connector', '连接地带', 'region')
}
}
.scrollable(ScrollDirection.Horizontal)
Scroll() {
Row({ space: 8 }) {
this.ExploreFilterChip('all', '全部主题', 'theme')
this.ExploreFilterChip('nature', '自然风光', 'theme')
this.ExploreFilterChip('culture', '人文古城', 'theme')
this.ExploreFilterChip('food', '美食小吃', 'theme')
}
}
.scrollable(ScrollDirection.Horizontal)
}
}
地区和主题都用了横向滚动,因为中文标签长度不固定。与其硬塞在一行导致压缩,不如让它自然横滑。
搜索框:输入和提交分离
搜索框的核心代码:
TextInput({ placeholder: '搜索景点 / 美食 / 城市 / 路线', text: this.searchText })
.layoutWeight(1)
.height(44)
.borderRadius(22)
.backgroundColor('#FFFFFF')
.fontSize(14)
.onChange((value: string) => {
this.searchText = value;
})
Button('搜索')
.width(64)
.height(44)
.onClick(() => {
this.commitSearchTerm(this.searchText);
})
这里采用了“输入即改变搜索词,点击搜索才写入历史”的策略。
这样设计的好处是:
- 输入过程中列表可以即时过滤。
- 搜索历史不会被每个半成品输入污染。
- 用户点击热门词或历史词时,也可以复用同一套提交逻辑。
搜索历史去重
提交搜索词的逻辑:
private commitSearchTerm(term: string): void {
const keyword = term.trim();
if (keyword.length === 0) {
return;
}
this.searchText = keyword;
this.searchHistory = [keyword, ...this.searchHistory.filter((item: string) => item !== keyword)].slice(0, 6);
this.persistSearchHistory();
}
这段代码完成了四件事:
- 去掉首尾空格。
- 空关键词不处理。
- 新关键词放到最前面。
- 旧列表里相同关键词先移除,避免重复。
- 最多保留 6 条。
搜索历史功能不复杂,但如果不做去重和数量限制,体验会很快变乱。
本地搜索匹配
景点和美食的匹配逻辑:
private matchItem(item: AtlasItem): boolean {
if (this.searchText.trim().length === 0) {
return true;
}
const keyword = this.searchText.trim().toLowerCase();
return item.title.toLowerCase().indexOf(keyword) >= 0 ||
item.english.toLowerCase().indexOf(keyword) >= 0 ||
item.region.toLowerCase().indexOf(keyword) >= 0 ||
item.summary.toLowerCase().indexOf(keyword) >= 0;
}
路线匹配类似:
private matchRoute(item: RouteItem): boolean {
if (this.searchText.trim().length === 0) {
return true;
}
const keyword = this.searchText.trim().toLowerCase();
return item.title.toLowerCase().indexOf(keyword) >= 0 ||
item.region.toLowerCase().indexOf(keyword) >= 0 ||
item.summary.toLowerCase().indexOf(keyword) >= 0;
}
这里没有引入复杂搜索引擎,而是基于标题、英文名、地区、摘要做包含匹配。对当前内容量来说,这已经足够。
如果后续内容扩展到几百条,可以再做:
- 搜索字段预处理。
- 拼音字段。
- 标签匹配。
- 简单倒排索引。
- 搜索结果排序权重。
MVP 阶段先把体验闭环做完整更重要。
地区筛选:用业务规则归类
项目没有为每条数据额外写 regionType,而是通过地区文本判断:
private isNorthRegion(region: string): boolean {
return region.indexOf('北疆') >= 0 || region.indexOf('阿勒泰') >= 0 ||
region.indexOf('乌鲁木齐') >= 0 || region.indexOf('伊犁') >= 0 ||
region.indexOf('博尔塔拉') >= 0 || region.indexOf('昌吉') >= 0;
}
private isSouthRegion(region: string): boolean {
return region.indexOf('喀什') >= 0 || region.indexOf('和田') >= 0 ||
region.indexOf('库车') >= 0 || region.indexOf('阿克苏') >= 0 ||
region.indexOf('巴州') >= 0 || region.indexOf('轮台') >= 0 ||
region.indexOf('南疆') >= 0;
}
这种写法对 MVP 很快,但也有升级空间。更严谨的方式是在数据模型里增加:
regionGroup: 'north' | 'south' | 'connector'
这样筛选就不依赖字符串。但当前项目内容量可控,字符串归类可以接受。
主题筛选:基于 tags
主题筛选使用 tags:
private matchTheme(tags: string[]): boolean {
if (this.exploreTheme === 'all') {
return true;
}
if (this.exploreTheme === 'nature') {
return tags.some((tag: string) => ['湖泊', '雪山', '草原', '森林', '沙漠', '日落'].indexOf(tag) >= 0);
}
if (this.exploreTheme === 'culture') {
return tags.some((tag: string) => ['古城', '人文', '市集', '文化'].indexOf(tag) >= 0);
}
return tags.some((tag: string) => ['美食', '主食', '小吃', '羊肉', '烧烤'].indexOf(tag) >= 0);
}
这是一种很适合内容 App 的做法:数据里不需要为每个主题单独建字段,只要维护好标签,页面就可以根据标签组合出不同主题视图。
最终列表如何生成
景点和美食列表:
private getExploreAtlasItems(): AtlasItem[] {
const source: AtlasItem[] = this.exploreType === 'food' ? this.foods : this.spots;
return this.sortAtlasItems(source
.filter((item: AtlasItem) => this.matchItem(item))
.filter((item: AtlasItem) => this.matchRegion(item.region))
.filter((item: AtlasItem) => this.matchTheme(item.tags)));
}
路线列表:
private getExploreRouteItems(): RouteItem[] {
return this.sortRouteItems(this.routes
.filter((item: RouteItem) => this.matchRoute(item))
.filter((item: RouteItem) => this.matchRegion(item.region))
.filter((item: RouteItem) => this.matchTheme(item.tags)));
}
这里的链式过滤非常清楚:
- 关键词匹配
- 地区匹配
- 主题匹配
- 排序
页面展示时只关心结果:
if (this.exploreType === 'spot') {
ForEach(this.getExploreAtlasItems(), (item: AtlasItem) => {
this.FeatureCard(item)
}, (item: AtlasItem) => item.id)
} else if (this.exploreType === 'food') {
ForEach(this.getExploreAtlasItems(), (item: AtlasItem) => {
this.ListCard(item)
}, (item: AtlasItem) => item.id)
}
这就是状态驱动 UI 的优势。筛选逻辑集中在方法里,UI 只负责根据类型选择卡片。
空状态不能省
探索页还有空状态:
if ((this.exploreType === 'city' && this.getExploreCities().length === 0) ||
(this.exploreType === 'route' && this.getExploreRouteItems().length === 0) ||
((this.exploreType === 'spot' || this.exploreType === 'food') && this.getExploreAtlasItems().length === 0)) {
this.EmptyCard('没有匹配内容', '可以切换地区、主题或清空搜索关键词后再试。')
}
空状态的价值是告诉用户“不是 App 坏了,而是当前条件没有结果”。尤其是多条件筛选时,用户很容易把搜索词、地区、主题组合成无结果状态。
一个成熟的 App,不能只设计有数据时的页面。
本篇小结
探索页的核心经验可以总结为:
- 搜索词、类型、地区、主题、排序分别建状态。
- 输入和提交搜索历史分离。
- 本地搜索先覆盖标题、英文名、地区、摘要。
- 地区筛选和主题筛选都集中在纯函数里。
- 列表渲染只消费最终过滤结果。
- 无结果时必须给出明确空状态。
下一篇我们看本地持久化:如何用 Preferences 保存收藏、搜索历史和行程清单,让单机 App 也拥有真实的个人化体验。
更多推荐



所有评论(0)