这一篇讲一个很实用的链路:不接后端,如何在本地内容库上完成搜索、类型切换、地区筛选、主题筛选、排序和无结果提示。探索页是内容 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();
}

这段代码完成了四件事:

  1. 去掉首尾空格。
  2. 空关键词不处理。
  3. 新关键词放到最前面。
  4. 旧列表里相同关键词先移除,避免重复。
  5. 最多保留 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)));
}

这里的链式过滤非常清楚:

  1. 关键词匹配
  2. 地区匹配
  3. 主题匹配
  4. 排序

页面展示时只关心结果:

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 也拥有真实的个人化体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐