在这里插入图片描述

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

随机推荐页是一个有趣的功能,每次访问都会随机展示一部动漫。这篇来讲随机推荐页的实现,重点是单条数据展示、收藏功能集成和刷新交互设计。

功能设计

随机推荐页需要实现以下功能:

  • 随机获取 - 每次加载随机获取一部动漫
  • 详情展示 - 展示动漫的封面、标题、评分、简介等
  • 收藏功能 - 可以直接收藏当前动漫
  • 换一个 - 点击按钮重新随机获取
  • 错误处理 - 加载失败时显示重试按钮

这个页面和之前的列表页面完全不同,它只展示一条数据,但展示得更加详细。用户可以在这里快速了解一部动漫,决定是否要深入查看或收藏。

随机推荐是一种"发现"功能。当用户不知道看什么的时候,随机推荐可以给他们惊喜。这种功能在音乐、视频、阅读类应用中很常见,比如网易云音乐的"私人FM"。

获取屏幕尺寸

用于计算图片尺寸:

const { width } = Dimensions.get('window');

用途说明:

  • 获取屏幕宽度
  • 用于设置封面图的尺寸

Dimensions.get('window') 返回屏幕的宽度和高度。我们只需要宽度,所以用解构语法 { width } 提取。

为什么需要屏幕宽度?因为封面图要占满屏幕宽度,高度按比例计算。这样可以保证在不同尺寸的设备上都有好的显示效果。

状态定义

这个页面的状态比较简单:

const [anime, setAnime] = useState<Anime | null>(null);
const [loading, setLoading] = useState(true);

状态说明:

  • anime - 当前随机获取的动漫,可能为 null
  • loading - 加载状态

和列表页面不同,这里只有一条数据,所以用 Anime | null 类型而不是 Anime[]。初始值是 null,表示还没有加载数据。

没有分页相关的状态,因为每次只加载一条数据。这让页面逻辑变得非常简单。

收藏功能集成

从全局状态获取收藏相关的方法:

const { isFavorite, toggleFavorite } = useStore();

方法说明:

  • isFavorite(id) - 判断某个动漫是否已收藏
  • toggleFavorite(anime) - 切换收藏状态

useStore 是我们封装的全局状态 Hook,基于 Zustand 实现。收藏数据存储在全局状态中,所有页面都可以访问和修改。

为什么在这个页面集成收藏功能?因为随机推荐的场景很适合收藏。用户看到一部感兴趣的动漫,可以直接收藏,以后再看。如果要先进入详情页才能收藏,操作路径就太长了。

数据加载函数

加载随机动漫:

const loadRandom = async () => {
  setLoading(true);
  try {
    const res = await getRandomAnime();
    setAnime(res.data);
  } catch (error) {
    console.error('Load error:', error);
  } finally {
    setLoading(false);
  }
};

加载逻辑:

  • 先设置 loading 为 true
  • 调用 API 获取随机动漫
  • 成功时设置 anime 数据
  • 失败时打印错误日志
  • 最终重置 loading 状态

getRandomAnime 是封装好的 API 函数。Jikan API 提供了随机获取动漫的接口,每次调用返回不同的结果。

注意这里没有用 setAnime(res.data || null),因为如果 API 返回空数据,anime 会是 undefined,后面的条件渲染会处理这种情况。

初始加载

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

说明:

  • 组件挂载时加载一次
  • 依赖数组为空,只执行一次

和列表页面一样的模式。但这个页面还有一个特点:用户可以手动触发重新加载(点击"换一个"按钮),所以 loadRandom 函数需要在组件外部可调用。

跳转详情页

const handleViewDetail = () => {
  if (anime) {
    navigation.navigate('AnimeDetail', { animeId: anime.mal_id });
  }
};

逻辑说明:

  • 检查 anime 是否存在
  • 存在则跳转到详情页

为什么要检查 if (anime)?因为 anime 可能是 null(加载失败或还在加载中)。虽然在 UI 上,只有 anime 存在时才会显示"查看详情"按钮,但加一个检查更安全。

加载状态渲染

if (loading) {
  return (
    <View style={styles.container}>
      <Header title="随机推荐" showBack onBack={() => navigation.goBack()} />
      <Loading fullScreen text="正在为你挑选..." />
    </View>
  );
}

设计亮点:

  • Loading 文字是"正在为你挑选…“而不是"加载中…”
  • 更有趣味性,符合随机推荐的场景

文案的选择很重要。"正在为你挑选…"暗示系统在为用户精心挑选内容,比"加载中…"更有温度。这种小细节可以提升用户体验。

错误状态渲染

if (!anime) {
  return (
    <View style={styles.container}>
      <Header title="随机推荐" showBack onBack={() => navigation.goBack()} />
      <View style={styles.errorContainer}>
        <Icon name="error" size={48} color={Colors.textMuted} />
        <Text style={styles.errorText}>加载失败</Text>
        <TouchableOpacity style={styles.retryButton} onPress={loadRandom}>
          <Text style={styles.retryText}>重试</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

错误处理:

  • 显示错误图标和文字
  • 提供重试按钮
  • 点击重试重新加载

错误状态的处理很重要。网络请求可能失败,用户需要知道发生了什么,以及如何解决。提供重试按钮让用户可以自己尝试恢复。

注意这里的条件是 !anime 而不是检查某个 error 状态。这是一种简化的处理方式:如果加载完成但 anime 为空,就认为是失败了。

收藏状态判断

const favorite = isFavorite(anime.mal_id);

说明:

  • 调用全局状态的方法判断是否已收藏
  • 结果用于控制收藏按钮的样式和文字

这行代码放在条件渲染之后,确保 anime 不为 null。如果放在前面,anime 为 null 时会报错。

Header 配置

<Header 
  title="随机推荐" 
  showBack 
  onBack={() => navigation.goBack()}
  rightIcon="dice"
  onRightPress={loadRandom}
/>

配置说明:

  • 标题"随机推荐"
  • 显示返回按钮
  • 右侧显示骰子图标
  • 点击骰子重新随机

骰子图标(dice)非常贴切,象征随机性。用户点击骰子就像掷骰子一样,会得到一个随机结果。这种隐喻让交互更直观。

在 Header 中放置刷新按钮是一个好的设计。用户不需要滚动到页面底部就能刷新,操作更便捷。

封面图展示

<View style={styles.imageContainer}>
  <Image
    source={{ uri: anime.images.jpg.large_image_url }}
    style={styles.image}
    resizeMode="cover"
  />
  <View style={styles.overlay} />
  {anime.score && (
    <View style={styles.scoreContainer}>
      <ScoreDisplay score={anime.score} size="lg" />
    </View>
  )}
</View>

布局结构:

  • 外层容器控制尺寸
  • Image 显示封面图
  • overlay 是半透明遮罩层
  • 评分显示在右下角

resizeMode="cover" 让图片填满容器,可能会裁剪部分内容。这比 contain(完整显示但可能有空白)更适合封面图展示。

遮罩层(overlay)的作用是让图片上的文字更容易阅读。纯图片上放白色文字可能看不清,加一层半透明黑色遮罩可以增加对比度。

封面图样式

imageContainer: {
  width: width,
  height: width * 1.2,
  position: 'relative',
},
image: {
  width: '100%',
  height: '100%',
},
overlay: {
  ...StyleSheet.absoluteFillObject,
  backgroundColor: 'rgba(0,0,0,0.3)',
},

样式详解:

  • 容器宽度等于屏幕宽度
  • 高度是宽度的 1.2 倍(竖向长图)
  • position: 'relative' 让子元素可以绝对定位
  • 图片填满容器
  • 遮罩层用 absoluteFillObject 覆盖整个容器

StyleSheet.absoluteFillObject 是 React Native 提供的便捷样式,等价于 { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }

width * 1.2 的比例是根据动漫海报的常见比例选择的。动漫海报通常是竖向的,宽高比大约是 2:3 或 3:4。1.2 的比例接近 5:6,显示效果不错。

内容区域

<View style={styles.content}>
  <Text style={styles.title}>{anime.title}</Text>
  {anime.title_japanese && (
    <Text style={styles.japaneseTitle}>{anime.title_japanese}</Text>
  )}

标题显示:

  • 主标题(通常是英文或罗马音)
  • 日文标题(如果有的话)

日文标题用条件渲染,因为不是所有动漫都有日文标题。有些动漫是非日本制作的,可能没有日文名。

content: {
  padding: Spacing.lg,
  marginTop: -Spacing.xxl,
  backgroundColor: Colors.background,
  borderTopLeftRadius: BorderRadius.xl,
  borderTopRightRadius: BorderRadius.xl,
},

内容区域样式:

  • 负的 marginTop 让内容区域向上覆盖图片
  • 圆角让过渡更自然
  • 背景色覆盖图片

这是一种常见的设计模式:内容区域向上延伸,覆盖部分图片,形成"卡片浮起"的效果。圆角增加了视觉层次感。

标签展示

<View style={styles.badges}>
  {anime.type && <Badge text={anime.type} />}
  {anime.status && <Badge text={anime.status} color={Colors.accent} />}
  {anime.episodes && <Badge text={`${anime.episodes}`} color={Colors.backgroundLight} />}
</View>

标签内容:

  • 类型(TV、Movie、OVA 等)
  • 状态(正在播出、已完结等)
  • 集数

Badge 组件是封装好的标签组件,支持自定义颜色。不同类型的信息用不同颜色区分,视觉上更清晰。

分类标签

<View style={styles.genres}>
  {anime.genres?.slice(0, 4).map(genre => (
    <TouchableOpacity
      key={genre.mal_id}
      style={styles.genreTag}
      onPress={() => navigation.navigate('GenreAnime', { 
        genreId: genre.mal_id, 
        genreName: genre.name 
      })}
    >
      <Text style={styles.genreText}>{genre.name}</Text>
    </TouchableOpacity>
  ))}
</View>

分类标签功能:

  • 最多显示 4 个分类
  • 每个分类可点击
  • 点击跳转到该分类的动漫列表

slice(0, 4) 限制最多显示 4 个分类。有些动漫可能有很多分类标签,全部显示会占用太多空间。

分类标签可点击是一个很好的设计。用户看到感兴趣的分类,可以直接点击查看更多同类动漫,增加了内容发现的路径。

简介展示

{anime.synopsis && (
  <Text style={styles.synopsis} numberOfLines={6}>
    {anime.synopsis}
  </Text>
)}

简介配置:

  • 条件渲染,有简介才显示
  • 最多显示 6 行,超出部分省略

numberOfLines={6} 限制文本行数。简介可能很长,全部显示会占用太多空间。用户如果想看完整简介,可以点击"查看详情"进入详情页。

操作按钮

<View style={styles.actions}>
  <TouchableOpacity 
    style={[styles.actionButton, styles.primaryButton]}
    onPress={handleViewDetail}
  >
    <Icon name="info" size={20} />
    <Text style={styles.actionText}>查看详情</Text>
  </TouchableOpacity>
  <TouchableOpacity 
    style={[styles.actionButton, favorite && styles.favoriteActive]}
    onPress={() => toggleFavorite(anime)}
  >
    <Icon name={favorite ? 'heart-fill' : 'heart'} size={20} color={favorite ? Colors.accent : Colors.text} />
    <Text style={[styles.actionText, favorite && styles.favoriteText]}>
      {favorite ? '已收藏' : '收藏'}
    </Text>
  </TouchableOpacity>
</View>

两个操作按钮:

  • 查看详情 - 跳转到详情页
  • 收藏 - 切换收藏状态

收藏按钮的状态变化:

  • 未收藏:空心爱心图标,文字"收藏"
  • 已收藏:实心爱心图标,文字"已收藏",背景色变化

收藏按钮的视觉反馈很重要。用户点击后,图标、文字、颜色都会变化,让用户明确知道操作成功了。

换一个按钮

<TouchableOpacity style={styles.refreshButton} onPress={loadRandom}>
  <Icon name="dice" size={24} />
  <Text style={styles.refreshText}>换一个</Text>
</TouchableOpacity>

按钮设计:

  • 骰子图标 + "换一个"文字
  • 虚线边框,区别于其他按钮
  • 点击重新加载随机动漫
refreshButton: {
  flexDirection: 'row',
  alignItems: 'center',
  justifyContent: 'center',
  gap: Spacing.sm,
  paddingVertical: Spacing.lg,
  backgroundColor: Colors.backgroundCard,
  borderRadius: BorderRadius.lg,
  borderWidth: 1,
  borderColor: Colors.border,
  borderStyle: 'dashed',
},

样式特点:

  • 虚线边框(dashed)
  • 较大的内边距
  • 居中布局

虚线边框是一个视觉暗示,表示这是一个"可选"或"额外"的操作,而不是主要操作。主要操作(查看详情、收藏)用实心按钮,次要操作(换一个)用虚线按钮。

小结

随机推荐页是一个单条数据展示页面,和列表页面的设计思路完全不同。页面展示了动漫的封面、标题、评分、分类、简介等详细信息,用户可以直接收藏或查看详情。

"换一个"功能是这个页面的核心交互,用户可以通过 Header 的骰子图标或底部的按钮触发。骰子图标的隐喻让交互更直观有趣。

收藏功能的集成展示了如何在页面中使用全局状态。通过 useStore Hook 获取收藏相关的方法,可以判断收藏状态和切换收藏。

错误处理也是这个页面的亮点,加载失败时显示友好的错误提示和重试按钮,让用户可以自己尝试恢复。

下一篇会讲收藏页面,展示用户收藏的所有动漫。


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

Logo

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

更多推荐