在这里插入图片描述

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

全部分类页展示所有动漫分类,用户可以点击分类查看该类型的动漫列表。这篇来讲全部分类页的实现,重点是彩色卡片的循环配色和双列网格布局。

功能设计

全部分类页需要实现以下功能:

  • 分类列表 - 展示所有动漫分类(如动作、冒险、喜剧等)
  • 彩色卡片 - 每个分类用不同颜色的卡片展示
  • 点击跳转 - 点击分类跳转到该分类的动漫列表
  • 双列布局 - 一行两个分类,充分利用空间

这个页面的特点是用彩色卡片让分类列表更有视觉吸引力。如果所有卡片都是同一种颜色,页面会显得单调乏味。通过给每个分类分配不同的颜色,可以让页面更加生动有趣,也方便用户快速定位到想要的分类。

颜色配置

定义一组预设颜色,用于分类卡片的背景:

const GENRE_COLORS = [
  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#DDA0DD',
  '#F7DC6F', '#BB8FCE', '#85C1E9', '#F8B500', '#58D68D',
  '#EC7063', '#5DADE2', '#AF7AC5', '#48C9B0', '#F5B041',
];

颜色选择的考量:

  • 共 15 种不同的颜色,覆盖红、绿、蓝、黄、紫等主要色系
  • 颜色饱和度适中,不会太刺眼,长时间看也不会疲劳
  • 都是中等亮度的颜色,白色文字在上面清晰可读
  • 相邻颜色差异明显,视觉上容易区分

为什么选择 15 种颜色?动漫分类大约有 20-30 种,15 种颜色循环使用可以保证:

  1. 相邻的分类颜色不同,视觉上有变化
  2. 颜色种类足够多,不会显得重复
  3. 数量适中,容易维护

颜色的选择是一门学问。太深的颜色(如深蓝、深紫)会让白色文字不够清晰;太浅的颜色(如浅黄、浅粉)会让卡片不够醒目,在深色背景上对比度不足。这组颜色是经过多次调试的,在深色主题下效果很好。

状态定义

const [genres, setGenres] = useState<Genre[]>([]);
const [loading, setLoading] = useState(true);

状态说明:

  • genres - 分类列表数据,存储从 API 获取的所有分类
  • loading - 加载状态,控制 Loading 组件的显示

这个页面的状态非常简单,只有数据和加载状态两个。不需要分页状态,因为分类数量是固定的(大约 20-30 个),一次性加载即可。也不需要筛选状态,因为分类本身就是用来筛选动漫的。

简单的状态设计是好事,说明页面功能单一、职责清晰。复杂的状态往往意味着页面承担了太多功能,应该考虑拆分。

数据加载

useEffect(() => {
  loadGenres();
}, []);

const loadGenres = async () => {
  try {
    const data = await getGenres();
    setGenres(data.data || []);
  } catch (error) {
    console.error('Load genres error:', error);
  } finally {
    setLoading(false);
  }
};

加载逻辑详解:

  • useEffect 的依赖数组为空,表示只在组件挂载时执行一次
  • getGenres 是封装好的 API 函数,返回所有动漫分类
  • data.data || [] 处理 API 返回空数据的情况
  • 错误时打印日志,不会导致页面崩溃
  • finally 确保无论成功失败都会把 loading 设为 false

分类数据是相对固定的,不会经常变化。MyAnimeList 的分类列表多年来基本没有变过。所以这个 API 的响应可以考虑做本地缓存,减少网络请求。

为什么用 data.data || [] 而不是直接用 data.data?这是一种防御性编程。如果 API 返回的数据结构不符合预期(比如 data 是 null),直接访问 data.data 会报错。用 || [] 可以确保 genres 始终是数组,后续的 map 操作不会出错。

分类卡片渲染

const renderItem = ({ item, index }: { item: Genre; index: number }) => (
  <TouchableOpacity
    style={[styles.genreCard, { backgroundColor: GENRE_COLORS[index % GENRE_COLORS.length] }]}
    onPress={() => navigation.navigate('GenreAnime', { 
      genreId: item.mal_id, 
      genreName: item.name 
    })}
  >
    <Text style={styles.genreName}>{item.name}</Text>
    <Icon name="forward" size={18} color="rgba(255,255,255,0.7)" />
  </TouchableOpacity>
);

渲染逻辑详解:

  • renderItem 接收 item(当前分类数据)和 index(当前索引)
  • index % GENRE_COLORS.length 计算颜色索引,实现循环配色
  • 点击时跳转到 GenreAnime 页面,传递分类 ID 和名称
  • 卡片内容包括分类名称和一个向右的箭头图标

index % GENRE_COLORS.length 是取模运算(求余数),这是实现循环的关键。假设 GENRE_COLORS.length 是 15:

  • index = 0: 0 % 15 = 0,使用第 1 种颜色(红色)
  • index = 7: 7 % 15 = 7,使用第 8 种颜色(蓝色)
  • index = 14: 14 % 15 = 14,使用第 15 种颜色(橙色)
  • index = 15: 15 % 15 = 0,回到第 1 种颜色(红色)
  • index = 16: 16 % 15 = 1,使用第 2 种颜色(青色)

这种循环使用颜色的方式非常实用,可以用有限的颜色覆盖无限的列表项。无论分类有多少个,都能保证每个分类有颜色。

箭头图标用半透明白色 rgba(255,255,255,0.7),而不是纯白色。这样箭头既能看清,又不会太抢眼,不会分散用户对分类名称的注意力。

genreCard: {
  width: '48%',
  flexDirection: 'row',
  alignItems: 'center',
  justifyContent: 'space-between',
  padding: Spacing.lg,
  borderRadius: BorderRadius.lg,
  marginBottom: Spacing.md,
},
genreName: {
  fontSize: FontSize.md,
  fontWeight: '600',
  color: Colors.text,
},

卡片样式详解:

  • width: '48%' - 两个卡片加上间距正好占满一行
  • flexDirection: 'row' - 横向布局,名称在左,箭头在右
  • justifyContent: 'space-between' - 名称和箭头分布在两端
  • alignItems: 'center' - 垂直居中
  • 内边距和圆角让卡片更精致

为什么用 48% 而不是 50%?因为 FlatList 的 columnWrapperStyle 设置了 justifyContent: 'space-between',会在两个卡片之间自动留出间距。如果用 50%,两个卡片会紧贴在一起没有间距;用 48%,剩下的 4% 就是中间的间距。

这种百分比宽度的设计可以适应不同屏幕宽度。无论是小屏手机还是大屏平板,两个卡片都能正确显示。

页面结构

return (
  <View style={styles.container}>
    <Header
      title="全部分类"
      showBack
      onBack={() => navigation.goBack()}
    />

Header 配置:

  • 标题显示"全部分类",简洁明了
  • 显示返回按钮,点击返回上一页
  • 没有副标题,因为这是一级浏览页面,不需要额外说明

这个页面是从个人中心跳转过来的,所以需要返回按钮。如果是 Tab 页面(如首页、搜索页),通常不需要返回按钮。

条件渲染

    {loading ? (
      <Loading fullScreen />
    ) : (
      <FlatList
        data={genres}
        renderItem={renderItem}
        keyExtractor={(item) => item.mal_id.toString()}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.list}
        showsVerticalScrollIndicator={false}
      />
    )}
  </View>
);

两种状态的处理:

  • 加载中显示全屏 Loading
  • 加载完成显示分类列表

这里没有空状态处理(EmptyState),因为分类数据不可能为空。只要 API 正常工作,一定会返回分类列表。如果 API 出错,会在 catch 中处理,loading 会变成 false,但 genres 保持为空数组,FlatList 会显示空白。

在实际项目中,可以考虑加一个错误状态,当 API 出错时显示"加载失败,点击重试"。但对于这个简单的页面,目前的处理已经足够。

FlatList 配置详解

<FlatList
  data={genres}
  renderItem={renderItem}
  keyExtractor={(item) => item.mal_id.toString()}
  numColumns={2}
  columnWrapperStyle={styles.row}
  contentContainerStyle={styles.list}
  showsVerticalScrollIndicator={false}
/>

配置说明:

  • data={genres} - 数据源是分类数组
  • renderItem={renderItem} - 渲染函数
  • keyExtractor - 用分类 ID 作为 key,保证唯一性
  • numColumns={2} - 两列布局,这是实现网格的关键
  • columnWrapperStyle - 每行的样式
  • contentContainerStyle - 内容容器的样式
  • showsVerticalScrollIndicator={false} - 隐藏滚动条

numColumns 是 FlatList 实现网格布局的核心属性。设置为 2 后,FlatList 会自动把数据分成多行,每行显示 2 个元素。这比手动计算布局简单得多。

keyExtractor 返回的必须是字符串,所以用 toString() 把数字 ID 转换为字符串。

row: {
  justifyContent: 'space-between',
},
list: {
  padding: Spacing.lg,
},

布局样式:

  • justifyContent: 'space-between' 让每行的两个卡片分布在两端,中间自动留出间距
  • 列表有内边距,让内容不贴边

分类数据结构

分类的数据结构在 types 中定义:

interface Genre {
  mal_id: number;
  name: string;
  count: number;
}

字段说明:

  • mal_id - 分类 ID,用于请求该分类的动漫
  • name - 分类名称,如 “Action”、“Adventure”、“Comedy”
  • count - 该分类下的动漫数量

这里没有显示 count(动漫数量),但可以考虑在卡片上显示,比如 “Action (1234)”。这样用户可以知道每个分类有多少动漫,对热门分类有个概念。

分类名称是英文的,如果需要中文显示,可以做一个映射表,把英文名称转换为中文。但考虑到动漫爱好者通常熟悉这些英文术语,保持英文也是可以接受的。

导航跳转

点击分类卡片会跳转到分类动漫页面:

navigation.navigate('GenreAnime', { 
  genreId: item.mal_id, 
  genreName: item.name 
})

参数说明:

  • genreId - 分类 ID,用于请求该分类的动漫列表
  • genreName - 分类名称,用于显示在下一个页面的 Header

为什么要传 genreName?因为下一个页面(GenreAnime)需要在 Header 中显示分类名称,让用户知道当前在看哪个分类的动漫。如果不传,下一个页面就需要再请求一次分类信息,或者显示一个通用的标题。

这种"传递显示用的数据"是一个常见的模式。虽然下一个页面可以根据 ID 请求数据,但传递已有的数据可以让页面更快显示,用户体验更好。

颜色循环的原理

GENRE_COLORS[index % GENRE_COLORS.length]

取模运算的数学原理:

取模运算(%)返回除法的余数。对于任意非负整数 index 和正整数 length:

  • index % length 的结果总是在 0 到 length-1 之间
  • 当 index 增加时,结果会循环:0, 1, 2, …, length-1, 0, 1, 2, …

这是一个非常实用的编程技巧,在很多场景都能用到:

  • 循环使用颜色(如本例)
  • 循环播放列表
  • 轮播图的无限滚动
  • 游戏中的循环地图

与其他页面的关系

全部分类页在应用的导航结构中的位置:

  • 入口 - 从个人中心页面的"全部分类"菜单项进入
  • 出口 - 点击分类跳转到分类动漫页面(GenreAnime)
// 个人中心的跳转代码
navigation.navigate('AllGenres')

// 全部分类的跳转代码
navigation.navigate('GenreAnime', { genreId, genreName })

这形成了一个清晰的导航链:个人中心 → 全部分类 → 分类动漫 → 动漫详情。用户可以通过返回按钮一步步返回,也可以通过底部 Tab 直接切换到其他页面。

小结

全部分类页的核心是彩色卡片和循环配色。用取模运算循环使用预设颜色,让每个分类都有不同的颜色,视觉上更有吸引力,也方便用户快速定位。

双列布局用 FlatList 的 numColumns 属性实现,卡片宽度用百分比设置,可以适应不同屏幕宽度。这种响应式设计在移动端开发中很重要。

点击分类跳转到分类动漫页面,传递分类 ID 和名称。传递名称可以让下一个页面立即显示标题,不需要等待数据加载。

下一篇会讲分类动漫页面,展示某个分类下的所有动漫,涉及到分页加载和网格布局。


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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐