React Native for OpenHarmony 实战:Steam 资讯 App 游戏分类页面

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

前面几篇我们实现了首页、精选、特惠等页面,这些页面都是展示特定的游戏列表。这一篇来实现游戏分类页面,让用户可以按照不同的分类浏览游戏。分类页面是一个中间层,用户先选择分类,然后进入该分类的游戏列表。
请添加图片描述

为什么需要分类页面

在实际的游戏应用中,分类是一个很重要的功能。Steam 上有几百个游戏,如果没有分类,用户很难找到自己感兴趣的游戏。比如有人只想玩 RPG 游戏,有人只想玩射击游戏,分类功能就能满足这些需求。

分类页面的设计思路是:

  • 展示所有分类 - 用网格或列表展示所有可用的游戏分类
  • 分类统计 - 显示每个分类下有多少个游戏
  • 快速导航 - 用户点击分类后快速进入该分类的游戏列表
  • 分类搜索 - 如果分类很多,可以提供搜索功能快速找到想要的分类

Steam 的分类系统

Steam 的游戏分类主要有两种:

类型分类(Genre) - 比如 RPG、射击、策略、冒险等。这是按照游戏的玩法特性分类的。

标签分类(Tag) - 比如"单人"、“多人”、"支持手柄"等。这是按照游戏的特征标签分类的。

这篇文章主要讲类型分类的实现。标签分类会在下一篇讲。

分类数据的获取

Steam 没有直接提供获取所有分类的 API,所以我们需要手动维护一份分类列表。这是一个常见的做法,很多应用都这样做。

首先在 API 文件中定义分类列表:

export const GAME_CATEGORIES = [
  {id: 'action', name: '动作', icon: '⚔️', color: '#e74c3c'},
  {id: 'adventure', name: '冒险', icon: '🗺️', color: '#3498db'},
  {id: 'rpg', name: 'RPG', icon: '🎮', color: '#9b59b6'},
  {id: 'strategy', name: '策略', icon: '♟️', color: '#f39c12'},
  {id: 'simulation', name: '模拟', icon: '🚗', color: '#1abc9c'},
  {id: 'puzzle', name: '益智', icon: '🧩', color: '#e67e22'},
  {id: 'casual', name: '休闲', icon: '🎯', color: '#2ecc71'},
  {id: 'sports', name: '体育', icon: '⚽', color: '#34495e'},
  {id: 'racing', name: '竞速', icon: '🏎️', color: '#c0392b'},
  {id: 'indie', name: '独立', icon: '🎨', color: '#16a085'},
];

这里的设计思路:

  • id - 分类的唯一标识,用于后续查询该分类的游戏
  • name - 分类的显示名称
  • icon - 分类的 emoji 图标,用于视觉识别
  • color - 分类的主题颜色,用于美化 UI

每个分类都有自己的颜色和图标,这样用户可以快速识别不同的分类。

分类页面的状态管理

分类页面相对简单,只需要管理几个基本的状态:

export const CategoriesScreen = () => {
  const {navigate, setSelectedCategory} = useApp();
  const [categories, setCategories] = useState(GAME_CATEGORIES);
  const [searchQuery, setSearchQuery] = useState('');
  const [filteredCategories, setFilteredCategories] = useState(GAME_CATEGORIES);

状态的作用:

  • categories - 所有分类列表,初始值是预定义的分类数组
  • searchQuery - 搜索框的输入内容
  • filteredCategories - 过滤后的分类列表,根据搜索词过滤

这样的设计让分类搜索功能很容易实现。

分类搜索的实现

当用户在搜索框输入内容时,需要实时过滤分类列表:

const handleSearchChange = (text: string) => {
  setSearchQuery(text);
  
  if (text.length === 0) {
    setFilteredCategories(categories);
  } else {
    const filtered = categories.filter(cat =>
      cat.name.toLowerCase().includes(text.toLowerCase())
    );
    setFilteredCategories(filtered);
  }
};

这里的实现细节:

  • 实时过滤 - 用户每输入一个字符,就重新过滤一次分类列表
  • 大小写不敏感 - 用 toLowerCase() 将搜索词和分类名都转换成小写,这样搜索"RPG"和"rpg"都能找到
  • 空搜索处理 - 如果搜索框为空,显示所有分类

这样的实现虽然简单,但对于分类数量不多的情况(通常不超过 20 个)完全够用。

分类卡片组件

分类卡片是分类页面的核心组件,需要展示分类的图标、名称和游戏数量:

const CategoryCard = ({category, onPress}: {category: any, onPress: () => void}) => {
  return (
    <TouchableOpacity
      style={[styles.categoryCard, {borderLeftColor: category.color}]}
      onPress={onPress}
    >
      <Text style={styles.categoryIcon}>{category.icon}</Text>
      <Text style={styles.categoryName}>{category.name}</Text>
      <Text style={styles.categoryCount}>查看游戏 ›</Text>
    </TouchableOpacity>
  );
};

这里的设计:

  • 动态边框颜色 - 用 borderLeftColor: category.color 给每个卡片加上不同颜色的左边框,这样可以快速区分不同分类
  • 图标展示 - 用 emoji 图标表示分类,简洁直观
  • 点击处理 - 点击卡片时调用 onPress 回调函数

这样的卡片设计既美观又实用。

分类列表的渲染

分类列表用网格布局展示,这样可以充分利用屏幕空间:

<View style={styles.categoriesGrid}>
  {filteredCategories.map((category) => (
    <View key={category.id} style={styles.categoryItem}>
      <CategoryCard
        category={category}
        onPress={() => {
          setSelectedCategory(category.id);
          navigate('categoryGames');
        }}
      />
    </View>
  ))}
</View>

这里的实现:

  • 网格布局 - 用 flexWrap: 'wrap' 实现网格布局,每行显示 2 个分类
  • 点击导航 - 点击分类卡片时,设置选中的分类 ID,然后导航到分类游戏列表页面
  • key 属性 - 用分类 ID 作为 key,确保列表项的唯一性

搜索框的实现

分类页面顶部需要一个搜索框,让用户可以快速找到想要的分类:

<View style={styles.searchContainer}>
  <TextInput
    style={styles.searchInput}
    placeholder="搜索分类..."
    placeholderTextColor="#8f98a0"
    value={searchQuery}
    onChangeText={handleSearchChange}
  />
  {searchQuery.length > 0 && (
    <TouchableOpacity
      style={styles.clearBtn}
      onPress={() => {
        setSearchQuery('');
        setFilteredCategories(categories);
      }}
    >
      <Text style={styles.clearBtnText}>✕</Text>
    </TouchableOpacity>
  )}
</View>

这里的设计:

  • 搜索框 - 用 TextInput 组件实现,placeholder 提示用户可以搜索分类
  • 清空按钮 - 只有当搜索框有内容时才显示,点击可以快速清空搜索词
  • 实时搜索 - 用户输入时实时过滤分类列表

这样的搜索框设计很常见,用户已经很熟悉了。

空状态的处理

当搜索结果为空时,需要显示提示信息:

{filteredCategories.length === 0 ? (
  <View style={styles.emptyContainer}>
    <Text style={styles.emptyText}>未找到相关分类</Text>
    <Text style={styles.emptySubtext}>试试其他搜索词</Text>
  </View>
) : (
  <View style={styles.categoriesGrid}>
    {/* 分类列表 */}
  </View>
)}

这里的设计:

  • 空状态提示 - 显示友好的提示信息,告诉用户没有找到匹配的分类
  • 建议 - 提示用户"试试其他搜索词",引导用户改变搜索策略

这样的空状态处理让应用看起来更专业。

完整页面的实现

现在把所有部分组合在一起,看看完整的分类页面:

import React, {useState} from 'react';
import {View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet} from 'react-native';
import {Header} from '../components/Header';
import {TabBar} from '../components/TabBar';
import {useApp} from '../store/AppContext';
import {GAME_CATEGORIES} from '../api/steam';

export const CategoriesScreen = () => {
  const {navigate, setSelectedCategory} = useApp();
  const [searchQuery, setSearchQuery] = useState('');
  const [filteredCategories, setFilteredCategories] = useState(GAME_CATEGORIES);

  const handleSearchChange = (text: string) => {
    setSearchQuery(text);
    
    if (text.length === 0) {
      setFilteredCategories(GAME_CATEGORIES);
    } else {
      const filtered = GAME_CATEGORIES.filter(cat =>
        cat.name.toLowerCase().includes(text.toLowerCase())
      );
      setFilteredCategories(filtered);
    }
  };

  const CategoryCard = ({category}: {category: any}) => (
    <TouchableOpacity
      style={[styles.categoryCard, {borderLeftColor: category.color}]}
      onPress={() => {
        setSelectedCategory(category.id);
        navigate('categoryGames');
      }}
    >
      <Text style={styles.categoryIcon}>{category.icon}</Text>
      <View style={styles.categoryInfo}>
        <Text style={styles.categoryName}>{category.name}</Text>
        <Text style={styles.categoryAction}>查看游戏 ›</Text>
      </View>
    </TouchableOpacity>
  );

  return (
    <View style={styles.container}>
      <Header title="游戏分类" showBack={false} />
      
      <View style={styles.searchContainer}>
        <TextInput
          style={styles.searchInput}
          placeholder="搜索分类..."
          placeholderTextColor="#8f98a0"
          value={searchQuery}
          onChangeText={handleSearchChange}
        />
        {searchQuery.length > 0 && (
          <TouchableOpacity
            style={styles.clearBtn}
            onPress={() => {
              setSearchQuery('');
              setFilteredCategories(GAME_CATEGORIES);
            }}
          >
            <Text style={styles.clearBtnText}>✕</Text>
          </TouchableOpacity>
        )}
      </View>

      <ScrollView style={styles.content}>
        {filteredCategories.length === 0 ? (
          <View style={styles.emptyContainer}>
            <Text style={styles.emptyText}>未找到相关分类</Text>
            <Text style={styles.emptySubtext}>试试其他搜索词</Text>
          </View>
        ) : (
          <View style={styles.categoriesGrid}>
            {filteredCategories.map((category) => (
              <View key={category.id} style={styles.categoryItem}>
                <CategoryCard category={category} />
              </View>
            ))}
          </View>
        )}
      </ScrollView>

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

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  searchContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 12,
    backgroundColor: '#1b2838',
    borderBottomWidth: 1,
    borderBottomColor: '#2a475e',
  },
  searchInput: {
    flex: 1,
    height: 40,
    paddingHorizontal: 12,
    borderRadius: 8,
    backgroundColor: '#2a475e',
    color: '#acdbf5',
    fontSize: 14,
  },
  clearBtn: {
    width: 40,
    height: 40,
    justifyContent: 'center',
    alignItems: 'center',
    marginLeft: 8,
  },
  clearBtnText: {fontSize: 20, color: '#8f98a0'},
  content: {flex: 1, padding: 12},
  categoriesGrid: {flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between'},
  categoryItem: {width: '48%', marginBottom: 12},
  categoryCard: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 12,
    backgroundColor: '#1b2838',
    borderRadius: 8,
    borderLeftWidth: 4,
  },
  categoryIcon: {fontSize: 28, marginRight: 12},
  categoryInfo: {flex: 1},
  categoryName: {fontSize: 14, fontWeight: '600', color: '#acdbf5', marginBottom: 4},
  categoryAction: {fontSize: 12, color: '#66c0f4'},
  emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: 60},
  emptyText: {fontSize: 16, color: '#8f98a0', marginBottom: 8},
  emptySubtext: {fontSize: 12, color: '#8f98a0'},
});

页面的整体结构:

  • Header - 显示"游戏分类"标题
  • 搜索框 - 让用户可以搜索分类
  • 分类网格 - 用网格布局展示所有分类
  • 空状态 - 当搜索结果为空时显示提示
  • TabBar - 底部导航栏

分类页面的设计考虑

为什么用网格布局而不是列表布局? 网格布局可以充分利用屏幕宽度,每行显示 2 个分类,这样用户可以一眼看到更多分类。列表布局虽然也可以,但会浪费屏幕空间。

为什么要加搜索功能? 虽然现在只有 10 个分类,但如果后续要加更多分类(比如 20 个、30 个),搜索功能就很有用了。而且搜索功能的实现成本很低,不如提前加上。

为什么要显示分类的颜色和图标? 这样可以让分类更容易被识别。用户可以通过颜色和图标快速找到想要的分类,而不需要仔细阅读文字。

与其他页面的联动

分类页面本身只是一个中间层,真正的游戏列表在下一个页面(分类游戏列表页面)。所以分类页面需要和其他页面配合:

导航流程 - 用户在首页点击"分类"入口 → 进入分类页面 → 选择一个分类 → 进入该分类的游戏列表页面

数据传递 - 分类页面需要把选中的分类 ID 传递给下一个页面,这样下一个页面才能知道要显示哪个分类的游戏

这种分层设计让应用的结构更清晰,每个页面的职责也更明确。

实际开发中的经验

在实际开发中,我发现分类页面虽然看起来简单,但有几个细节需要注意:

分类数据的维护 - 分类列表是手动维护的,所以需要定期更新。如果 Steam 新增了分类,我们需要手动添加到列表中。这是一个小的维护成本,但值得。

分类的排序 - 分类的顺序很重要。我们应该把最受欢迎的分类放在前面,这样用户可以快速找到。比如"动作"和"RPG"通常比"体育"和"竞速"更受欢迎。

分类的翻译 - 如果应用要支持多语言,分类名称也需要翻译。这可以通过国际化库(比如 i18n)来实现。

小结

分类页面是一个相对简单但很重要的页面。它为用户提供了一个快速浏览不同类型游戏的方式。虽然功能不复杂,但设计得好的分类页面可以大大提升用户体验。

关键是要:

  • 清晰的分类 - 分类要清晰明确,用户能快速理解
  • 快速的导航 - 用户点击分类后能快速进入游戏列表
  • 搜索功能 - 提供搜索功能,方便用户快速找到想要的分类
  • 视觉识别 - 用颜色和图标帮助用户快速识别分类

下一篇我们来实现分类游戏列表页面,展示某个分类下的所有游戏。


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

Logo

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

更多推荐