RN for OpenHarmony AnimeHub项目实战:季度新番页面开发
摘要 本文介绍了动漫应用中新番页面的实现方案,核心功能包括: 数据展示:采用两列网格布局展示动漫列表,支持分页加载 筛选交互:实现年份选择器和季度选择器,支持用户按时间维度浏览 智能默认:自动获取当前年份和季度作为默认筛选条件 状态管理:使用useEffect监听筛选条件变化,自动触发数据刷新 性能优化:区分首次加载和追加加载的loading状态,避免不必要的全屏加载提示 技术亮点包括动态生成年份
案例开源地址: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]);
依赖监听:
把
selectedYear和selectedSeason放在依赖数组里,当用户切换年份或季度时,自动重新加载数据。注意这里页码写死为 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
更多推荐



所有评论(0)