React Native for OpenHarmony 实战:Steam 资讯 App 搜索结果页面

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam

上一篇我们实现了搜索功能,用户可以输入关键词搜索游戏。这一篇来聊搜索结果页面的实现。搜索结果页面需要展示搜索到的游戏列表,支持排序、过滤等功能,让用户能更方便地找到想要的游戏。
请添加图片描述

搜索结果页面的需求

搜索结果页面的核心功能包括:

  • 展示搜索结果列表 - 显示匹配搜索词的游戏
  • 排序功能 - 支持按相关性、价格、评分等排序
  • 过滤功能 - 支持按游戏类型、价格范围等过滤
  • 分页加载 - 当结果很多时,支持加载更多
  • 结果统计 - 显示搜索结果的总数

这些功能看起来复杂,但实现起来其实不难。关键是要理清数据流和状态管理。

为什么需要搜索结果页面

在实际开发中,我发现搜索功能不能只停留在"能搜"的阶段。用户搜索出来的结果可能有几十甚至几百个,如果不提供排序和过滤的能力,用户就很难从这么多结果中找到真正想要的游戏。

比如用户搜索"RPG",可能会得到几百个结果。如果没有排序功能,用户只能一个一个往下翻。如果有按价格排序的功能,用户可以快速找到便宜的 RPG 游戏。如果有按类型过滤的功能,用户可以只看免费的 RPG 游戏。

所以搜索结果页面的设计,直接影响到用户的搜索体验。

搜索结果的数据结构

从 Steam API 获取的搜索结果数据结构大概是这样的:

{
  "total": 150,
  "apps": [
    {
      "id": 730,
      "name": "Counter-Strike 2",
      "logo": "https://cdn.cloudflare.steamstatic.com/steam/apps/730/logo.png",
      "price": "免费游玩"
    }
  ]
}

关键字段说明:

  • total - 搜索结果总数,告诉用户一共有多少个匹配的游戏
  • apps - 游戏列表数组,包含所有搜索到的游戏
  • id - 游戏的 AppId,这是 Steam 中游戏的唯一标识,用于跳转详情页
  • name - 游戏名称,直接显示给用户
  • logo - 游戏 logo 图片 URL,用于展示游戏的缩略图
  • price - 游戏价格,可能是具体价格、"免费游玩"或其他价格信息

我们需要在这个基础上,添加排序和过滤的逻辑。API 返回的数据是原始的,我们需要根据用户的选择进行处理。

排序和过滤的实现

首先定义排序和过滤的选项。这里用 TypeScript 的类型系统来确保类型安全:

type SortType = 'relevance' | 'price_asc' | 'price_desc' | 'rating';
type FilterType = 'all' | 'free' | 'paid' | 'discount';

interface SearchFilters {
  sort: SortType;
  filter: FilterType;
  priceMin?: number;
  priceMax?: number;
}

这里的设计思路:

  • SortType - 定义了四种排序方式:相关性(API 返回的默认顺序)、价格升序、价格降序、评分。相关性是默认的,因为 Steam API 已经按相关性排序了
  • FilterType - 定义了四种过滤方式:全部游戏、只显示免费游戏、只显示付费游戏、只显示打折游戏
  • SearchFilters - 组合排序和过滤条件,还支持价格范围过滤(虽然这篇文章暂时没用到)

接下来实现排序和过滤的核心逻辑。这个函数会接收原始的游戏列表和过滤条件,返回处理后的列表:

const applyFiltersAndSort = (games: any[], filters: SearchFilters) => {
  let result = [...games];

  // 应用过滤
  if (filters.filter === 'free') {
    result = result.filter(g => g.price === '免费游玩' || g.price === '免费');
  } else if (filters.filter === 'paid') {
    result = result.filter(g => g.price && g.price !== '免费游玩' && g.price !== '免费');
  } else if (filters.filter === 'discount') {
    result = result.filter(g => g.discount_percent && g.discount_percent > 0);
  }

  // 应用排序
  if (filters.sort === 'price_asc') {
    result.sort((a, b) => {
      const priceA = parseFloat(a.price) || 0;
      const priceB = parseFloat(b.price) || 0;
      return priceA - priceB;
    });
  } else if (filters.sort === 'price_desc') {
    result.sort((a, b) => {
      const priceA = parseFloat(a.price) || 0;
      const priceB = parseFloat(b.price) || 0;
      return priceB - priceA;
    });
  }

  return result;
};

这里的实现细节:

  • 创建副本 - 用 [...games] 创建一个新数组,避免修改原始数据。这样做的好处是,如果用户改变过滤条件,我们可以基于原始数据重新过滤,而不是基于已经过滤过的数据
  • 过滤逻辑 - 根据 filter 类型过滤游戏列表。注意这里用的是 filter() 方法,它会返回一个新数组
  • 排序逻辑 - 根据 sort 类型对列表进行排序。用 sort() 方法,它会修改原数组(但这里没关系,因为我们已经创建了副本)
  • 价格解析 - 用 parseFloat 将价格字符串转换成数字,便于比较。如果转换失败,用 || 0 作为默认值

这个函数的设计很关键。它是纯函数,不会修改输入的数据,只返回新的结果。这样的设计让代码更容易测试和维护。

搜索结果页面的状态管理

搜索结果页面需要管理的状态比较多。让我们逐个分析每个状态的作用:

export const SearchResultsScreen = () => {
  const {navigate, setSelectedAppId, addToHistory} = useApp();
  const [searchQuery, setSearchQuery] = useState('');
  const [allResults, setAllResults] = useState<any[]>([]);
  const [displayResults, setDisplayResults] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [filters, setFilters] = useState<SearchFilters>({
    sort: 'relevance',
    filter: 'all',
  });
  const [showFilters, setShowFilters] = useState(false);
  const [pageIndex, setPageIndex] = useState(0);

状态的作用:

  • searchQuery - 当前的搜索词,用于显示在页面顶部,告诉用户搜索的是什么
  • allResults - 所有搜索结果(未过滤)。这很重要,因为当用户改变过滤条件时,我们需要基于这个原始数据重新过滤
  • displayResults - 显示的结果(已过滤和排序)。这是最终要显示给用户的数据
  • loading - 加载状态,用于显示 Loading 组件或隐藏它
  • filters - 当前的排序和过滤条件。初始值是相关性排序、显示全部游戏
  • showFilters - 是否显示过滤面板。用户点击"排序"按钮时设为 true,点击"应用"或"关闭"时设为 false
  • pageIndex - 当前页码(用于分页加载)。初始值是 0,表示第一页

这样的状态设计有个好处:allResultsdisplayResults 的分离,让我们可以高效地处理过滤。用户改变过滤条件时,不需要重新请求 API,只需要基于 allResults 重新计算 displayResults

搜索结果的获取和处理

当用户从搜索页面跳转过来时,需要获取搜索结果。这里用 useEffect 来处理副作用:

useEffect(() => {
  const loadSearchResults = async () => {
    if (!searchQuery) return;
    
    setLoading(true);
    try {
      const data = await searchGames(searchQuery);
      const apps = data?.apps || [];
      setAllResults(apps);
      
      // 应用初始的排序和过滤
      const filtered = applyFiltersAndSort(apps, filters);
      setDisplayResults(filtered);
    } catch (error) {
      console.error('Search error:', error);
    } finally {
      setLoading(false);
    }
  };

  loadSearchResults();
}, [searchQuery]);

这里的流程:

  1. 检查搜索词 - 如果搜索词为空,直接返回,不执行后续逻辑
  2. 设置加载状态 - 设为 true,这样页面会显示 Loading 组件
  3. 调用搜索 API - 使用 searchGames 函数获取搜索结果
  4. 提取游戏列表 - 从 API 响应中提取 apps 字段,如果不存在则用空数组
  5. 存储原始结果 - 将结果存储到 allResults,这是后续过滤的基础
  6. 应用排序和过滤 - 调用 applyFiltersAndSort 函数,得到 displayResults
  7. 错误处理 - 用 try-catch 捕获错误,即使出错也会把 loading 设为 false
  8. 依赖数组 - [searchQuery] 表示只有当搜索词变化时才重新执行

这个 useEffect 的设计很重要。它确保了每当用户搜索新的关键词时,都会重新获取结果。

过滤条件变化时的处理

当用户改变排序或过滤条件时,需要重新处理结果。这里用另一个 useEffect 来处理:

useEffect(() => {
  const filtered = applyFiltersAndSort(allResults, filters);
  setDisplayResults(filtered);
  setPageIndex(0); // 重置页码
}, [filters]);

const handleFilterChange = (newFilters: Partial<SearchFilters>) => {
  setFilters(prev => ({...prev, ...newFilters}));
};

这里的设计:

  • 依赖数组 - [filters] 表示只有当过滤条件变化时才重新计算
  • 重新计算 - 基于 allResults 和新的 filters 重新计算 displayResults
  • 重置页码 - 因为过滤后的结果数量可能变化,所以要重置页码为 0
  • handleFilterChange - 这是一个辅助函数,用于更新过滤条件。用 ...prev 保留之前的条件,只更新新传入的条件

这样的设计让过滤逻辑很清晰:用户改变条件 → filters 变化 → useEffect 触发 → 重新计算 displayResults → 页面自动更新。

搜索结果列表的渲染

搜索结果列表用 FlatList 渲染,支持分页加载。这里只展示关键部分:

<FlatList
  data={displayResults.slice(0, (pageIndex + 1) * 10)}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({item}) => (
    <TouchableOpacity
      style={styles.resultItem}
      onPress={() => {
        setSelectedAppId(item.id);
        addToHistory(item.id);
        navigate('gameDetail');
      }}
    >
      <Image
        source={{uri: item.logo}}
        style={styles.resultLogo}
        cache="force-cache"
      />
      <View style={styles.resultInfo}>
        <Text style={styles.resultName} numberOfLines={1}>{item.name}</Text>
        <View style={styles.resultMeta}>
          <Text style={styles.resultPrice}>{item.price}</Text>
          {item.discount_percent > 0 && (
            <View style={styles.discountBadge}>
              <Text style={styles.discountText}>-{item.discount_percent}%</Text>
            </View>
          )}
        </View>
      </View>
    </TouchableOpacity>
  )}
  onEndReached={() => {
    if (pageIndex < Math.ceil(displayResults.length / 10) - 1) {
      setPageIndex(prev => prev + 1);
    }
  }}
  onEndReachedThreshold={0.5}
  ListEmptyComponent={
    <View style={styles.emptyContainer}>
      <Text style={styles.emptyText}>未找到相关游戏</Text>
    </View>
  }
/>

这里的关键点:

  • 分页加载 - displayResults.slice(0, (pageIndex + 1) * 10) 表示每次显示 10 条结果。当 pageIndex 为 0 时显示前 10 条,为 1 时显示前 20 条,以此类推
  • keyExtractor - 用游戏 ID 作为 key,确保列表项的唯一性
  • renderItem - 每个列表项显示游戏 logo、名称、价格和折扣信息
  • 点击处理 - 点击游戏卡片时,设置选中的游戏 ID、添加到浏览历史、跳转到详情页
  • onEndReached - 当用户滚动到列表底部时触发。这里检查是否还有更多页面,如果有就增加 pageIndex
  • onEndReachedThreshold - 设为 0.5,表示当用户滚动到列表底部 50% 时触发 onEndReached。这样用户不需要完全滚到底部就能触发加载
  • ListEmptyComponent - 当搜索结果为空时显示这个组件,提示用户"未找到相关游戏"

分页加载的好处是显而易见的:不需要一次性加载所有结果,可以减少内存占用,提高列表滚动的流畅度。

过滤面板的实现

过滤面板用 Modal 实现,用户点击"排序"按钮时显示。这里展示排序选项部分:

<Modal
  visible={showFilters}
  transparent
  animationType="slide"
  onRequestClose={() => setShowFilters(false)}
>
  <View style={styles.filterModal}>
    <View style={styles.filterHeader}>
      <Text style={styles.filterTitle}>排序和过滤</Text>
      <TouchableOpacity onPress={() => setShowFilters(false)}>
        <Text style={styles.closeBtn}>✕</Text>
      </TouchableOpacity>
    </View>

    <ScrollView style={styles.filterContent}>
      <View style={styles.filterSection}>
        <Text style={styles.filterSectionTitle}>排序方式</Text>
        {[
          {value: 'relevance', label: '相关性'},
          {value: 'price_asc', label: '价格:低到高'},
          {value: 'price_desc', label: '价格:高到低'},
        ].map(option => (
          <TouchableOpacity
            key={option.value}
            style={styles.filterOption}
            onPress={() => handleFilterChange({sort: option.value as SortType})}
          >
            <View style={[styles.radio, filters.sort === option.value && styles.radioSelected]} />
            <Text style={styles.filterOptionText}>{option.label}</Text>
          </TouchableOpacity>
        ))}
      </View>

这里的设计:

  • Modal 组件 - 用 animationType="slide" 让面板从下往上滑出来,这是常见的 UI 模式
  • filterHeader - 显示标题和关闭按钮
  • filterContent - 用 ScrollView 包裹,这样当选项很多时可以滚动
  • filterSection - 每个过滤类别(排序、游戏类型等)都是一个 section
  • 单选按钮 - 用 map 遍历选项数组,为每个选项创建一个单选按钮。当用户点击时,调用 handleFilterChange 更新过滤条件
  • radio 样式 - 根据 filters.sort === option.value 判断是否选中,选中时显示 radioSelected 样式

这样的设计让用户可以很直观地看到当前选中的选项。

过滤选项的完整实现

除了排序,还需要实现游戏类型的过滤。这里展示过滤选项部分:

<View style={styles.filterSection}>
  <Text style={styles.filterSectionTitle}>游戏类型</Text>
  {[
    {value: 'all', label: '全部'},
    {value: 'free', label: '免费游戏'},
    {value: 'paid', label: '付费游戏'},
    {value: 'discount', label: '打折游戏'},
  ].map(option => (
    <TouchableOpacity
      key={option.value}
      style={styles.filterOption}
      onPress={() => handleFilterChange({filter: option.value as FilterType})}
    >
      <View style={[styles.radio, filters.filter === option.value && styles.radioSelected]} />
      <Text style={styles.filterOptionText}>{option.label}</Text>
    </TouchableOpacity>
  ))}
</View>

<TouchableOpacity
  style={styles.applyBtn}
  onPress={() => setShowFilters(false)}
>
  <Text style={styles.applyBtnText}>应用</Text>
</TouchableOpacity>

这里的实现:

  • 游戏类型选项 - 提供了四种选择:全部、免费、付费、打折
  • 单选逻辑 - 同样用单选按钮实现,用户只能选一个
  • 应用按钮 - 点击后关闭面板。注意这里不需要额外的逻辑,因为过滤条件已经在 handleFilterChange 中实时更新了

这样的设计让用户可以快速改变过滤条件,而且改变是实时的。

工具栏的实现

在列表上方显示一个工具栏,显示搜索结果的总数和排序按钮:

<View style={styles.toolbar}>
  <Text style={styles.resultCount}>找到 {totalCount} 个结果</Text>
  <TouchableOpacity
    style={styles.filterBtn}
    onPress={() => setShowFilters(true)}
  >
    <Text style={styles.filterBtnText}>⚙️ 排序</Text>
  </TouchableOpacity>
</View>

这里的设计:

  • 结果统计 - 显示搜索结果的总数,让用户了解有多少个匹配的游戏
  • 排序按钮 - 点击打开过滤面板。用齿轮图标表示设置,这是常见的 UI 约定

这个工具栏很简洁,但提供了关键的信息和操作。

完整页面的核心逻辑

现在把所有部分组合在一起。这里展示页面的主要结构:

export const SearchResultsScreen = () => {
  // ... 状态定义 ...

  if (loading) {
    return (
      <View style={styles.container}>
        <Header title="搜索结果" showBack />
        <Loading />
        <TabBar />
      </View>
    );
  }

  const displayCount = (pageIndex + 1) * 10;
  const totalCount = displayResults.length;

  return (
    <View style={styles.container}>
      <Header title="搜索结果" showBack />
      
      <View style={styles.toolbar}>
        <Text style={styles.resultCount}>找到 {totalCount} 个结果</Text>
        <TouchableOpacity
          style={styles.filterBtn}
          onPress={() => setShowFilters(true)}
        >
          <Text style={styles.filterBtnText}>⚙️ 排序</Text>
        </TouchableOpacity>
      </View>

      <FlatList
        data={displayResults.slice(0, displayCount)}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({item}) => (
          // ... 列表项渲染 ...
        )}
        onEndReached={() => {
          if (pageIndex < Math.ceil(displayResults.length / 10) - 1) {
            setPageIndex(prev => prev + 1);
          }
        }}
        onEndReachedThreshold={0.5}
        ListEmptyComponent={
          <View style={styles.emptyContainer}>
            <Text style={styles.emptyText}>未找到相关游戏</Text>
          </View>
        }
      />

      {/* 过滤面板 Modal */}
      <Modal visible={showFilters} transparent animationType="slide">
        {/* ... 过滤面板内容 ... */}
      </Modal>

      <TabBar />
    </View>
  );
};

页面的整体结构:

  • Header - 显示"搜索结果"标题和返回按钮
  • Toolbar - 显示结果统计和排序按钮
  • FlatList - 显示搜索结果列表,支持分页加载
  • Modal - 过滤面板,用户点击排序按钮时显示
  • TabBar - 底部导航栏

这样的结构很清晰,每个部分都有明确的职责。

样式设计的考虑

样式设计需要考虑几个方面:

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  toolbar: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 12,
    backgroundColor: '#1b2838',
    borderBottomWidth: 1,
    borderBottomColor: '#2a475e',
  },
  resultCount: {fontSize: 14, color: '#8f98a0'},
  filterBtn: {paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#2a475e', borderRadius: 4},
  filterBtnText: {fontSize: 12, color: '#66c0f4'},
});

样式的设计思路:

  • 背景色 - 使用 Steam 的深色主题,#171a21 是最深的背景色
  • 工具栏 - 用 flexDirection: 'row' 让内容横排显示,justifyContent: 'space-between' 让内容两端对齐
  • 按钮样式 - 用 #2a475e 作为背景色,这是比主背景色稍浅的颜色,能清晰地区分按钮
  • 文字颜色 - 主要文字用 #acdbf5(浅蓝色),次要文字用 #8f98a0(灰色)

这样的配色方案保持了整个应用的视觉统一性。

关键实现细节总结

分页加载的优化: 每次加载 10 条结果,而不是一次性加载所有结果。这样可以减少内存占用,提高列表滚动的流畅度。特别是当搜索结果有几百条时,这个优化就很明显了。

排序和过滤的分离: 将原始结果存储在 allResults 中,排序和过滤后的结果存储在 displayResults 中。这样做的好处是,改变过滤条件时,不需要重新请求 API,只需要基于原始数据重新计算。这大大提高了应用的响应速度。

实时过滤反馈: 用户改变过滤条件时,结果会立即更新。这样用户能看到过滤的效果,不需要点击"应用"按钮。这提升了用户体验。

结果统计: 显示搜索结果的总数,让用户了解有多少个匹配的游戏。这个信息很重要,因为它告诉用户搜索是否成功。

实际开发中的经验

在实际开发中,我还遇到过一些有趣的问题。比如用户搜索"免费"时,结果可能包含很多免费游戏,但也可能包含一些名字里有"免费"的付费游戏。这时候过滤功能就很有用了,用户可以快速过滤出真正的免费游戏。

还有一个问题是价格的显示。Steam 的价格格式不统一,有的是"¥99",有的是"免费游玩",有的甚至没有价格信息。所以在排序时需要特别处理这些情况。我们用 parseFloat 将价格字符串转换成数字,如果转换失败就用 0 作为默认值。这样即使价格格式不统一,排序也能正常工作。

还有一个性能优化的技巧。当搜索结果很多时(比如几千条),一次性渲染所有结果会导致页面卡顿。所以我们用分页加载的方式,每次只渲染 10 条结果。当用户滚动到底部时,再加载下一页。这样可以大大提高页面的响应速度。

小结

搜索结果页面虽然功能多,但核心逻辑其实很清晰:获取搜索结果、应用排序和过滤、分页显示。关键是要合理管理状态,分离原始数据和处理后的数据。

这样的设计不仅提高了用户体验,也让代码更容易维护和扩展。如果后续要加新的排序或过滤方式,只需要在 applyFiltersAndSort 函数中添加新的逻辑就行。比如要加按评分排序,只需要在 SortType 中加 'rating',然后在 applyFiltersAndSort 中加相应的排序逻辑。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐