在这里插入图片描述

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

这篇来讲新番页的实现。新番页是动漫应用的核心功能之一,用户可以按年份和季度浏览动漫,追踪每个季度有什么新番上线。

功能规划

新番页需要实现这些功能:

  • 年份选择器,可以查看历史年份的动漫
  • 季度选择器,春夏秋冬四个季度
  • 动漫网格列表,两列布局
  • 分页加载,滚动到底部自动加载更多

这个页面的交互比较多,年份和季度的切换都要触发数据重新加载,状态管理要处理好。

季度和年份的处理

先定义一些常量和工具函数:

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

常量说明:

  • SEASONS - 四个季度的英文标识,和 API 参数对应
  • SEASON_NAMES - 英文到中文的映射,用于界面显示

API 接口用的是英文季度名,但界面上要显示中文。用一个映射对象来转换,比写四个 if-else 简洁。Record<string, string> 是 TypeScript 的类型,表示键和值都是字符串的对象。

const getCurrentSeason = () => {
  const month = new Date().getMonth();
  if (month >= 0 && month <= 2) return 'winter';
  if (month >= 3 && month <= 5) return 'spring';
  if (month >= 6 && month <= 8) return 'summer';
  return 'fall';
};

获取当前季度:

  • 1-3 月是冬季(month 是 0-2,因为 getMonth 从 0 开始)
  • 4-6 月是春季
  • 7-9 月是夏季
  • 10-12 月是秋季

这个函数用来设置默认选中的季度。用户进入页面时,默认显示当前季度的新番,符合使用习惯。

const getCurrentYear = () => new Date().getFullYear();

获取当前年份:

简单封装一下,语义更清晰。后面生成年份列表也会用到。

状态定义

const [animeList, setAnimeList] = useState<Anime[]>([]);
const [loading, setLoading] = useState(true);
const [selectedYear, setSelectedYear] = useState(getCurrentYear());
const [selectedSeason, setSelectedSeason] = useState(getCurrentSeason());
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);

状态说明:

  • animeList - 动漫列表数据
  • loading - 加载状态
  • selectedYear - 选中的年份,默认当前年
  • selectedSeason - 选中的季度,默认当前季度
  • page - 当前页码
  • hasMore - 是否还有更多数据

年份和季度的默认值用前面定义的函数获取,这样用户一进来就能看到当季新番,不用手动选择。

const years = Array.from({ length: 10 }, (_, i) => getCurrentYear() - i);

生成年份列表:

Array.from 创建一个长度为 10 的数组,第二个参数是映射函数,i 是索引。这样就生成了从当前年份往前数 10 年的数组,比如 [2024, 2023, 2022, …]。

数据加载

const loadData = async (year: number, season: string, pageNum = 1) => {
  setLoading(pageNum === 1);
  try {
    const data = await getSeasonAnime(year, season, pageNum);
    if (pageNum === 1) {
      setAnimeList(data.data || []);
    } else {
      setAnimeList(prev => [...prev, ...(data.data || [])]);
    }
    setHasMore(data.pagination?.has_next_page || false);
    setPage(pageNum);
  } catch (error) {
    console.error('Load seasonal error:', error);
  } finally {
    setLoading(false);
  }
};

加载逻辑:

  • 条件 loading - 只有第一页才显示全屏 loading,加载更多时不显示
  • 数据处理 - 第一页替换数据,后续页追加数据
  • 分页状态 - 从 API 响应中获取是否还有下一页

setLoading(pageNum === 1) 这个写法很巧妙。加载第一页时 loading 为 true,显示全屏加载;加载更多时 loading 为 false,只在列表底部显示小的加载提示。

useEffect(() => {
  loadData(selectedYear, selectedSeason, 1);
}, [selectedYear, selectedSeason]);

依赖监听:

selectedYearselectedSeason 放在依赖数组里,当用户切换年份或季度时,自动重新加载数据。注意这里页码写死为 1,因为切换筛选条件应该从第一页开始。

const handleLoadMore = () => {
  if (!loading && hasMore) {
    loadData(selectedYear, selectedSeason, page + 1);
  }
};

加载更多:

和搜索页一样的逻辑,检查 loading 和 hasMore 防止重复请求。

年份选择器

<ScrollView 
  horizontal 
  showsHorizontalScrollIndicator={false}
  style={styles.yearScroll}
  contentContainerStyle={styles.yearContainer}
>
  {years.map((year) => (
    <TouchableOpacity
      key={year}
      style={[styles.yearButton, selectedYear === year && styles.yearButtonActive]}
      onPress={() => setSelectedYear(year)}
    >
      <Text style={[styles.yearText, selectedYear === year && styles.yearTextActive]}>
        {year}
      </Text>
    </TouchableOpacity>
  ))}
</ScrollView>

实现方式:

  • 横向滚动 - 年份比较多,用 ScrollView horizontal 让用户可以左右滑动
  • 条件样式 - selectedYear === year && styles.yearButtonActive 选中时应用激活样式
  • 点击切换 - onPress 更新 selectedYear,触发 useEffect 重新加载数据
yearButton: {
  paddingHorizontal: Spacing.lg,
  paddingVertical: Spacing.sm,
  borderRadius: BorderRadius.full,
  backgroundColor: Colors.backgroundLight,
  marginRight: Spacing.sm,
},
yearButtonActive: {
  backgroundColor: Colors.primary,
},

按钮样式:

  • 默认状态 - 灰色背景,胶囊形状
  • 选中状态 - 主题色背景,文字变白

用两个样式类组合的方式,比在一个样式里写三元表达式更清晰。[styles.yearButton, selectedYear === year && styles.yearButtonActive] 这种数组写法,React Native 会自动合并样式。

季度选择器

<View style={styles.seasonContainer}>
  {SEASONS.map((season) => (
    <TouchableOpacity
      key={season}
      style={[styles.seasonButton, selectedSeason === season && styles.seasonButtonActive]}
      onPress={() => setSelectedSeason(season)}
    >
      <Text style={[styles.seasonText, selectedSeason === season && styles.seasonTextActive]}>
        {SEASON_NAMES[season]}
      </Text>
    </TouchableOpacity>
  ))}
</View>

与年份选择器的区别:

  • 固定布局 - 只有四个季度,不需要滚动,用 View 包裹
  • 等宽按钮 - 每个按钮 flex: 1,平分容器宽度
  • 中文显示 - 用 SEASON_NAMES[season] 转换成中文
seasonContainer: {
  flexDirection: 'row',
  paddingHorizontal: Spacing.lg,
  paddingVertical: Spacing.md,
  gap: Spacing.sm,
},
seasonButton: {
  flex: 1,
  paddingVertical: Spacing.md,
  borderRadius: BorderRadius.md,
  backgroundColor: Colors.backgroundLight,
  alignItems: 'center',
},

布局说明:

flexDirection: 'row' 让四个按钮横向排列,flex: 1 让每个按钮等宽。gap: Spacing.sm 设置按钮之间的间距,比用 margin 更方便。

动漫卡片组件

列表里的每个动漫用 AnimeCard 组件展示:

interface AnimeCardProps {
  anime: Anime;
  onPress: () => void;
  size?: 'sm' | 'md' | 'lg';
}

Props 说明:

  • anime - 动漫数据
  • onPress - 点击回调
  • size - 卡片尺寸,支持三种大小

size 参数让卡片可以在不同场景复用。首页横向列表用 sm,新番页网格用 md,详情页推荐可以用 lg。

const { width } = Dimensions.get('window');
const CARD_WIDTH = (width - Spacing.lg * 3) / 2;

宽度计算:

屏幕宽度减去左右边距和中间间距,除以 2 得到每个卡片的宽度。Spacing.lg * 3 是左边距 + 右边距 + 中间间距。

const cardWidth = size === 'sm' ? 120 : size === 'lg' ? width - Spacing.lg * 2 : CARD_WIDTH;
const imageHeight = size === 'sm' ? 160 : size === 'lg' ? 280 : 200;

尺寸适配:

  • sm - 固定 120 宽度,用于横向列表
  • md - 计算得出的宽度,用于两列网格
  • lg - 接近全屏宽度,用于大图展示
<View style={styles.imageContainer}>
  <Image
    source={{ uri: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url }}
    style={[styles.image, { height: imageHeight }]}
    resizeMode="cover"
  />
  {anime.score && (
    <View style={styles.scoreContainer}>
      <ScoreDisplay score={anime.score} size="sm" />
    </View>
  )}
  {anime.episodes && (
    <View style={styles.episodesContainer}>
      <Text style={styles.episodes}>{anime.episodes}</Text>
    </View>
  )}
</View>

图片区域:

  • 图片源 - 优先用大图,没有就用普通图
  • 评分徽章 - 绝对定位在左上角
  • 集数标签 - 绝对定位在右下角,半透明背景

position: 'absolute' 把评分和集数叠加在图片上,不占用额外空间。这种设计在视频应用里很常见。

scoreContainer: {
  position: 'absolute',
  top: Spacing.sm,
  left: Spacing.sm,
},
episodesContainer: {
  position: 'absolute',
  bottom: Spacing.sm,
  right: Spacing.sm,
  backgroundColor: Colors.overlay,
  paddingHorizontal: Spacing.sm,
  paddingVertical: 2,
  borderRadius: BorderRadius.sm,
},

定位样式:

Colors.overlay 是半透明黑色,让集数标签在各种颜色的封面上都能看清。

网格列表

<FlatList
  data={animeList}
  renderItem={renderItem}
  keyExtractor={(item) => item.mal_id.toString()}
  numColumns={2}
  columnWrapperStyle={styles.row}
  onEndReached={handleLoadMore}
  onEndReachedThreshold={0.5}
  showsVerticalScrollIndicator={false}
  contentContainerStyle={styles.list}
  ListFooterComponent={loading ? <Loading /> : null}
/>

FlatList 配置:

  • numColumns={2} - 两列布局
  • columnWrapperStyle - 每行的样式,用于控制列之间的间距
  • onEndReached - 滚动到底部触发加载更多
  • ListFooterComponent - 底部组件,加载时显示 Loading
const renderItem = ({ item }: { item: Anime }) => (
  <View style={styles.cardWrapper}>
    <AnimeCard
      anime={item}
      onPress={() => navigation.navigate('AnimeDetail', { animeId: item.mal_id })}
    />
  </View>
);

渲染函数:

用 View 包一层是为了控制卡片宽度。cardWrapper 设置 width: '48%',留出 4% 作为中间间距。

row: {
  justifyContent: 'space-between',
},
cardWrapper: {
  width: '48%',
},

布局技巧:

justifyContent: 'space-between' 让两个卡片分别靠左和靠右,中间自动产生间距。配合 width: '48%',间距就是 4%。这种方式比用 margin 更灵活。

条件渲染

{loading && page === 1 ? (
  <Loading fullScreen />
) : animeList.length === 0 ? (
  <EmptyState
    icon="calendar"
    title="暂无数据"
    message="该季度暂无动漫数据"
  />
) : (
  <FlatList ... />
)}

三种状态:

  • 首次加载 - loading && page === 1,显示全屏 Loading
  • 无数据 - 列表为空,显示空状态提示
  • 有数据 - 显示动漫列表

page === 1 这个条件很重要。加载更多时 loading 也是 true,但不应该显示全屏 Loading,只需要在列表底部显示小的加载提示。

小结

新番页的核心是年份和季度的联动筛选。用 useEffect 监听这两个状态的变化,自动触发数据重新加载,用户体验很流畅。

网格布局用 FlatList 的 numColumns 属性实现,配合 columnWrapperStyle 控制间距。这比用 flex wrap 的方式更高效,因为 FlatList 有虚拟列表优化,只渲染可见区域的内容。

下一篇会讲排行榜页面,涉及到多种排行类型的切换。


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

Logo

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

更多推荐