在这里插入图片描述

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

季度动漫页展示某个特定季度的所有动漫作品。用户从全部季度页点击某个季度后,会跳转到这个页面查看该季度的动漫列表。这篇来讲季度动漫页的实现,重点是路由参数的使用和分页加载。

功能设计

季度动漫页需要实现以下功能:

  • 动态标题 - 根据年份和季度显示标题,如"2024年春季"
  • 网格展示 - 两列网格展示动漫卡片
  • 分页加载 - 滚动到底部自动加载更多
  • 空状态处理 - 没有数据时显示友好提示

这个页面是全部季度页的下一级页面,两者配合使用。用户在全部季度页选择一个时间点,然后在这个页面浏览该时间点的所有动漫。这种"先选择再浏览"的模式在内容型应用中非常常见。

季度动漫页的数据量可能很大。热门季度(如 2024 年春季)可能有 50+ 部动漫,所以必须实现分页加载,不能一次性加载全部数据。

季节名称映射

和全部季度页一样,需要定义季节的中文名称:

const SEASON_NAMES: Record<string, string> = {
  winter: '冬季',
  spring: '春季',
  summer: '夏季',
  fall: '秋季',
};

映射说明:

  • 这个映射用于生成页面标题
  • API 返回的是英文季节名,需要转换为中文显示
  • Record<string, string> 类型确保类型安全

你可能会问:这个映射在全部季度页已经定义过了,为什么不抽取成公共模块?这是一个好问题。在实际项目中,如果多个页面都用到同样的映射,确实应该抽取到 src/constantssrc/utils 目录。但在这个教程项目中,为了让每个页面的代码独立完整,我们选择在每个页面单独定义。

代码复用和代码独立性是一对矛盾。复用可以减少重复,但会增加依赖;独立可以降低耦合,但会有重复代码。需要根据项目规模和团队习惯来权衡。

获取路由参数

从路由中获取年份和季度参数:

export const SeasonAnimeScreen = ({ navigation, route }: any) => {
  const { year, season } = route.params;

参数说明:

  • navigation - 导航对象,用于页面跳转和返回
  • route - 路由对象,包含传递过来的参数
  • year - 年份,数字类型,如 2024
  • season - 季节,字符串类型,如 ‘spring’

route.params 是 React Navigation 传递参数的标准方式。上一个页面通过 navigation.navigate('SeasonAnime', { year: 2024, season: 'spring' }) 传递参数,这个页面通过 route.params 接收。

这里用了 any 类型是为了简化代码。在正式项目中,应该定义完整的类型:

type RouteParams = {
  year: number;
  season: string;
};

然后用 route.params as RouteParams 或者配合 React Navigation 的类型系统使用。

状态定义

这个页面需要管理多个状态:

const [animeList, setAnimeList] = useState<Anime[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

状态详解:

  • animeList - 动漫列表数据,Anime 类型的数组
  • loading - 首次加载状态,控制全屏 Loading
  • loadingMore - 加载更多状态,控制底部 Loading
  • page - 当前页码,从 1 开始
  • hasMore - 是否还有更多数据,控制是否继续加载

为什么要区分 loadingloadingMore?因为用户体验不同:

  • 首次加载时,页面是空的,需要显示全屏 Loading,告诉用户"正在加载"
  • 加载更多时,页面已经有内容了,只需要在底部显示小的 Loading,不影响用户浏览已有内容

hasMore 状态非常重要。如果不判断是否还有更多数据,用户滚动到底部时会不断发起请求,即使已经没有数据了。这会浪费网络资源,也会给服务器带来不必要的压力。

数据加载函数

核心的数据加载逻辑:

const loadData = async (pageNum: number, append = false) => {
  try {
    if (pageNum === 1) setLoading(true);
    else setLoadingMore(true);
    
    const res = await getSeasonAnime(year, season, pageNum);
    const newData = res.data || [];

函数参数:

  • pageNum - 要加载的页码
  • append - 是否追加到现有数据,默认 false(替换)

加载状态控制:

  • 第一页时设置 loading(全屏 Loading)
  • 其他页时设置 loadingMore(底部 Loading)

append 参数的设计很巧妙。首次加载时 append = false,数据会替换现有列表;加载更多时 append = true,新数据会追加到列表末尾。一个函数处理两种场景,代码更简洁。

getSeasonAnime(year, season, pageNum) 是封装好的 API 函数,传入年份、季节和页码,返回该季度的动漫列表。API 会根据页码返回对应的数据片段。

    if (append) {
      setAnimeList(prev => [...prev, ...newData]);
    } else {
      setAnimeList(newData);
    }
    setHasMore(res.pagination?.has_next_page || false);

数据处理:

  • 追加模式:用展开运算符合并旧数据和新数据
  • 替换模式:直接设置新数据
  • 从 API 响应中获取是否还有下一页

setAnimeList(prev => [...prev, ...newData]) 是 React 状态更新的函数式写法。prev 是当前状态值,返回值是新状态。这种写法比 setAnimeList([...animeList, ...newData]) 更安全,因为它总是基于最新的状态值。

res.pagination?.has_next_page 使用了可选链操作符 ?.。如果 res.pagination 是 undefined 或 null,整个表达式返回 undefined,不会报错。然后 || false 确保最终值是布尔类型。

  } catch (error) {
    console.error('Load error:', error);
  } finally {
    setLoading(false);
    setLoadingMore(false);
  }
};

错误处理和清理:

  • catch 捕获错误并打印日志
  • finally 确保无论成功失败都重置加载状态

finally 块是清理工作的最佳位置。无论 try 块成功还是 catch 块执行,finally 都会执行。这保证了加载状态一定会被重置,不会出现"永远在加载"的 bug。

初始加载

组件挂载时加载第一页数据:

useEffect(() => {
  loadData(1);
}, [year, season]);

依赖数组说明:

  • 依赖 yearseason
  • 当年份或季节变化时,重新加载数据

为什么依赖数组包含 yearseason?虽然在当前实现中,这两个值来自路由参数,不会变化。但如果将来页面支持切换季度(比如添加上一季/下一季按钮),这个依赖就会起作用。

这是一种"防御性编程"的思想:即使当前不需要,也为将来的扩展做好准备。

加载更多处理

滚动到底部时触发加载更多:

const handleLoadMore = useCallback(() => {
  if (!loadingMore && hasMore) {
    const nextPage = page + 1;
    setPage(nextPage);
    loadData(nextPage, true);
  }
}, [loadingMore, hasMore, page]);

加载条件:

  • !loadingMore - 当前没有在加载中(防止重复请求)
  • hasMore - 还有更多数据可以加载

加载流程:

  1. 计算下一页页码
  2. 更新页码状态
  3. 调用 loadData 加载数据,append 设为 true

useCallback 是 React 的性能优化 Hook。它会缓存函数引用,只有当依赖项变化时才创建新函数。这对于传递给子组件的回调函数特别有用,可以避免不必要的重渲染。

为什么要检查 !loadingMore?因为 FlatList 的 onEndReached 可能会被多次触发。如果不检查,用户快速滚动时可能会发起多个重复请求。这个检查确保同一时间只有一个加载请求在进行。

列表项渲染

渲染每个动漫卡片:

const renderItem = ({ item }: { item: Anime }) => (
  <View style={styles.cardWrapper}>
    <AnimeCard
      anime={item}
      onPress={() => navigation.navigate('AnimeDetail', { animeId: item.mal_id })}
    />
  </View>
);

渲染逻辑:

  • 外层 cardWrapper 控制卡片的宽度和间距
  • AnimeCard 是封装好的动漫卡片组件
  • 点击卡片跳转到动漫详情页

为什么需要 cardWrapper?因为 FlatList 的 numColumns={2} 只是把列表分成两列,但不会自动处理卡片之间的间距。cardWrapper 用来控制每个卡片占据的空间和内边距。

item.mal_id 是动漫在 MyAnimeList 数据库中的唯一 ID。传递这个 ID 到详情页,详情页就可以用它来请求完整的动漫信息。

动态标题生成

根据路由参数生成页面标题:

const title = `${year}${SEASON_NAMES[season] || season}`;

标题格式:

  • 组合年份和季节名称
  • 如果季节名称映射不存在,使用原始英文名
  • 最终效果如"2024年春季"

SEASON_NAMES[season] || season 是一个容错处理。正常情况下,season 的值是 ‘winter’、‘spring’、‘summer’ 或 ‘fall’,都能在映射中找到。但如果 API 返回了意外的值,用 || season 确保至少能显示原始值,不会显示 undefined。

这种"优雅降级"的思想在前端开发中很重要。即使数据不符合预期,也要尽量让页面正常显示,而不是崩溃或显示空白。

加载状态渲染

首次加载时显示全屏 Loading:

if (loading) {
  return (
    <View style={styles.container}>
      <Header title={title} showBack onBack={() => navigation.goBack()} />
      <Loading fullScreen text="加载中..." />
    </View>
  );
}

设计考虑:

  • 即使在加载中,也显示 Header,让用户知道自己在哪个页面
  • 用户可以点击返回按钮取消加载
  • Loading 组件显示"加载中…"文字

为什么加载时也要显示 Header?这是用户体验的细节。如果整个页面都是 Loading,用户会感到迷茫:"我在哪?这是什么页面?"显示 Header 可以给用户一个锚点,让他们知道自己的位置。

另外,显示返回按钮让用户有"退路"。如果加载太慢或者用户改变主意,可以随时返回。这种"可控感"对用户体验很重要。

主页面结构

加载完成后显示动漫列表:

return (
  <View style={styles.container}>
    <Header title={title} showBack onBack={() => navigation.goBack()} />
    <FlatList
      data={animeList}
      renderItem={renderItem}
      keyExtractor={item => item.mal_id.toString()}
      numColumns={2}
      contentContainerStyle={styles.list}
      showsVerticalScrollIndicator={false}
      onEndReached={handleLoadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={loadingMore ? <Loading text="加载更多..." /> : null}
      ListEmptyComponent={<EmptyState icon="movie" title="暂无数据" />}
    />
  </View>
);

FlatList 配置详解:

  • data - 数据源
  • renderItem - 渲染函数
  • keyExtractor - 提取唯一 key
  • numColumns={2} - 两列网格布局
  • onEndReached - 滚动到底部的回调
  • onEndReachedThreshold={0.5} - 触发阈值,0.5 表示距离底部 50% 时触发
  • ListFooterComponent - 列表底部组件
  • ListEmptyComponent - 空列表时显示的组件

onEndReachedThreshold={0.5} 是一个重要的配置。值越大,触发加载的时机越早。0.5 意味着当用户滚动到列表一半时就开始加载下一页,这样等用户滚动到底部时,新数据可能已经加载好了,体验更流畅。

如果设置太小(如 0.1),用户会看到明显的加载等待;如果设置太大(如 0.9),可能会加载很多用户不会看的数据,浪费流量。0.5 是一个比较平衡的值。

底部加载组件

ListFooterComponent={loadingMore ? <Loading text="加载更多..." /> : null}

条件渲染:

  • 正在加载更多时显示 Loading 组件
  • 不在加载时显示 null(不显示任何内容)

这个 Loading 组件和全屏 Loading 不同,它只是一个小的加载指示器,显示在列表底部。用户可以继续浏览已加载的内容,同时知道更多内容正在加载。

空状态组件

ListEmptyComponent={<EmptyState icon="movie" title="暂无数据" />}

空状态处理:

  • animeList 为空数组时显示
  • 显示一个图标和"暂无数据"文字

空状态在什么情况下会出现?

  1. 很早的年份(如 1960 年代某些季度)可能没有动漫数据
  2. API 返回错误时,数据可能为空
  3. 网络问题导致数据加载失败

显示友好的空状态比显示空白页面好得多。空白页面会让用户困惑:"是没有数据还是出错了?"而明确的"暂无数据"提示让用户知道这是正常情况。

样式定义

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.background,
  },

容器样式:

  • flex: 1 占满整个屏幕
  • 使用主题背景色
  list: {
    padding: Spacing.sm,
  },

列表样式:

  • 四周有小内边距
  • 让内容不贴边
  cardWrapper: {
    flex: 1,
    maxWidth: '50%',
    padding: Spacing.xs,
  },
});

卡片包装样式:

  • flex: 1 让卡片平分宽度
  • maxWidth: '50%' 确保每行最多两个卡片
  • padding: Spacing.xs 卡片之间的间距

maxWidth: '50%'numColumns={2} 配合使用。FlatList 会把每行分成两列,每个卡片占一列。maxWidth: '50%' 确保卡片不会超过一半宽度,即使内容很多也不会撑开。

为什么用 padding 而不是 margin?在 Flexbox 布局中,padding 不会影响元素的 flex 计算,而 margin 会。用 padding 可以让两个卡片之间有间距,同时保持 flex: 1 的等宽效果。

与全部季度页的配合

这两个页面形成了一个完整的浏览流程:

  1. 用户进入全部季度页,看到所有年份和季度
  2. 用户点击某个季度(如"2024年春季")
  3. 跳转到季度动漫页,显示该季度的所有动漫
  4. 用户可以滚动浏览,点击某个动漫进入详情页

这是一种典型的"列表 → 详情"导航模式,但多了一层"分类 → 列表"。这种三层结构在内容型应用中很常见:

  • 第一层:分类/筛选(全部季度页)
  • 第二层:列表(季度动漫页)
  • 第三层:详情(动漫详情页)

分页加载的最佳实践

这个页面展示了分页加载的标准实现:

  • 状态分离 - 首次加载和加载更多用不同的状态
  • 防重复请求 - 检查 loadingMore 状态
  • 边界判断 - 检查 hasMore 状态
  • 数据追加 - 用展开运算符合并数据
  • 提前加载 - 设置合适的 threshold

分页加载是移动端开发的基本功。几乎所有的列表页面都需要分页,因为一次性加载所有数据会导致:

  1. 首屏加载慢
  2. 内存占用高
  3. 渲染性能差
  4. 浪费用户流量

小结

季度动漫页是一个标准的分页列表页面,展示了路由参数获取、分页加载、条件渲染等常用技术。页面从路由获取年份和季度参数,动态生成标题,然后加载对应的动漫数据。

分页加载的实现包括状态管理(loading、loadingMore、page、hasMore)、加载函数(支持首次加载和追加加载)、触发机制(onEndReached)和边界判断(防重复、判断是否还有更多)。

两列网格布局用 numColumns={2}cardWrapper 样式配合实现,卡片等宽分布,间距均匀。空状态和加载状态都有友好的 UI 反馈,提升用户体验。

下一篇会讲热门排行页面,展示评分最高的动漫作品。


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

Logo

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

更多推荐