在这里插入图片描述

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

推荐页展示和当前动漫相似的其他作品,帮助用户发现更多感兴趣的内容。这篇来讲推荐页的实现,重点是双列网格布局和卡片宽度的计算。

功能设计

推荐页需要展示以下内容:

  • 动漫封面 - 推荐动漫的海报图片
  • 动漫标题 - 最多显示两行
  • 推荐数 - 有多少用户推荐了这部动漫
  • 网格布局 - 一行两个,充分利用屏幕空间

点击卡片可以跳转到对应动漫的详情页,形成浏览链。

卡片宽度计算

双列布局需要精确计算卡片宽度:

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

计算公式解析:

  • width - 屏幕宽度
  • Spacing.lg * 3 - 左边距 + 中间间距 + 右边距
  • / 2 - 两列平分

假设 Spacing.lg 是 16,屏幕宽度是 375(iPhone SE):

  • 总间距 = 16 * 3 = 48
  • 可用宽度 = 375 - 48 = 327
  • 卡片宽度 = 327 / 2 = 163.5

这个计算确保两个卡片加上间距正好占满屏幕宽度,不会有多余的空白或溢出。

状态定义

const { animeId, title } = route.params;
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);

状态说明:

  • animeId - 当前动漫 ID,用于请求推荐数据
  • title - 当前动漫标题,显示在 Header 副标题
  • recommendations - 推荐列表数据
  • loading - 加载状态

推荐数据是基于当前动漫的,所以需要传入 animeId。API 会返回和这部动漫相似的其他作品。

数据加载

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

const loadRecommendations = async () => {
  try {
    const data = await getAnimeRecommendations(animeId);
    setRecommendations(data.data || []);
  } catch (error) {
    console.error('Load recommendations error:', error);
  } finally {
    setLoading(false);
  }
};

加载逻辑:

  • 组件挂载时加载数据
  • data.data || [] 处理空数据
  • 错误时打印日志

推荐数据通常不会太多,一次性加载即可,不需要分页。

推荐卡片渲染

const renderItem = ({ item }: { item: Recommendation }) => (
  <TouchableOpacity 
    style={styles.card}
    onPress={() => navigation.push('AnimeDetail', { animeId: item.entry.mal_id })}
  >

卡片入口:

  • 整个卡片可点击
  • navigation.push 跳转到详情页
  • 传递推荐动漫的 ID

为什么用 push 而不是 navigate?因为用户可能从动漫 A 的推荐页跳到动漫 B,再从动漫 B 的推荐页跳到动漫 C。用 push 可以保留完整的浏览历史,用户可以一步步返回。

    <Image
      source={{ uri: item.entry.images?.jpg?.image_url }}
      style={styles.image}
    />

封面图片:

  • 从嵌套结构中取图片 URL
  • 用可选链防止空值报错

推荐数据的结构是 item.entry.xxx,entry 里面才是动漫信息。这种嵌套设计是因为推荐本身也是一个实体,包含推荐数等额外信息。

    <View style={styles.info}>
      <Text style={styles.title} numberOfLines={2}>{item.entry.title}</Text>
      <View style={styles.votes}>
        <Icon name="heart" size={12} color={Colors.accent} />
        <Text style={styles.votesText}>{item.votes} 推荐</Text>
      </View>
    </View>
  </TouchableOpacity>
);

信息区域:

  • 标题最多两行,超出显示省略号
  • 推荐数用爱心图标 + 数字显示

推荐数(votes)表示有多少用户认为这两部动漫相似。数字越大,说明推荐越可靠。

页面结构

return (
  <View style={styles.container}>
    <Header
      title="相关推荐"
      subtitle={title}
      showBack
      onBack={() => navigation.goBack()}
    />

Header 配置:

  • 标题显示"相关推荐"
  • 副标题显示当前动漫名称
  • 显示返回按钮

副标题显示当前动漫名称,让用户知道这是哪部动漫的推荐列表。

条件渲染

    {loading ? (
      <Loading fullScreen />
    ) : recommendations.length === 0 ? (
      <EmptyState icon="sparkle" title="暂无推荐" />
    ) : (
      <FlatList
        data={recommendations}
        renderItem={renderItem}
        keyExtractor={(item) => item.entry.mal_id.toString()}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.list}
        showsVerticalScrollIndicator={false}
      />
    )}
  </View>
);

三种状态:

  • 加载中显示 Loading
  • 无推荐显示空状态,用星星图标
  • 有推荐显示网格列表

空状态用 sparkle(星星)图标,和推荐的主题呼应。

FlatList 网格配置

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

网格配置详解:

  • numColumns={2} - 设置为两列
  • columnWrapperStyle - 每行的样式
  • keyExtractor - 用动漫 ID 作为 key

numColumns 是 FlatList 实现网格布局的关键属性。设置后,FlatList 会自动把数据分成多行,每行显示指定数量的元素。

注意 keyExtractor 里用的是 item.entry.mal_id,因为数据是嵌套结构。

row: {
  justifyContent: 'space-between',
},

行样式:

  • justifyContent: 'space-between' 让两个卡片分布在两端
  • 中间的间距自动计算

配合卡片宽度的计算,两个卡片会正好占满一行,中间留出 Spacing.lg 的间距。

卡片样式

card: {
  width: CARD_WIDTH,
  backgroundColor: Colors.backgroundCard,
  borderRadius: BorderRadius.lg,
  marginBottom: Spacing.md,
  overflow: 'hidden',
},

卡片容器:

  • 宽度用计算好的 CARD_WIDTH
  • 卡片背景色,圆角
  • overflow: 'hidden' 让图片圆角生效
  • 底部间距

overflow: 'hidden' 很重要,如果不设置,图片会超出圆角边界。

image: {
  width: '100%',
  height: 180,
  backgroundColor: Colors.backgroundLight,
},

图片样式:

  • 宽度 100% 填满卡片
  • 高度固定 180
  • 设置背景色作为加载占位

图片高度固定可以保证所有卡片高度一致,视觉上更整齐。180 的高度配合卡片宽度,大约是 1:1.1 的比例,接近海报的常见比例。

info: {
  padding: Spacing.md,
},
title: {
  fontSize: FontSize.md,
  fontWeight: '600',
  color: Colors.text,
  lineHeight: 20,
},

信息区域样式:

  • 内边距让内容不贴边
  • 标题加粗,设置行高

lineHeight: 20 配合 numberOfLines={2} 可以让两行标题的高度固定为 40。

votes: {
  flexDirection: 'row',
  alignItems: 'center',
  marginTop: Spacing.sm,
  gap: Spacing.xs,
},
votesText: {
  fontSize: FontSize.xs,
  color: Colors.textSecondary,
},

推荐数样式:

  • 横向布局,图标和文字在一行
  • 用 gap 设置间距
  • 小字号,次要颜色

推荐数据结构

推荐的数据结构在 types 中定义:

interface Recommendation {
  entry: {
    mal_id: number;
    title: string;
    images: {
      jpg: {
        image_url: string;
      };
    };
  };
  votes: number;
}

字段说明:

  • entry - 推荐的动漫信息
  • votes - 推荐数,表示有多少用户推荐

这个结构和角色列表、出演作品的结构类似,都是嵌套设计。entry 里面是动漫的基本信息,外层是推荐相关的信息。

与新番页的对比

推荐页和新番页都是网格布局,但有一些区别:

  • 数据来源 - 新番页是全局数据,推荐页是某部动漫的相关数据
  • 卡片内容 - 新番页显示评分,推荐页显示推荐数
  • 分页 - 新番页有分页,推荐页没有

虽然布局类似,但因为数据结构和展示内容不同,没有抽象成通用组件。如果项目中有更多类似的页面,可以考虑抽象。

导航跳转

点击推荐卡片会跳转到动漫详情页:

navigation.push('AnimeDetail', { animeId: item.entry.mal_id })

跳转说明:

  • 用 push 创建新页面实例
  • 传递推荐动漫的 ID

用户可以从推荐页不断跳转到新的动漫,形成探索链。每次跳转都是新页面,返回时可以一步步回到之前的页面。

推荐算法

Jikan API 的推荐是基于用户投票的:

  • 用户可以在 MyAnimeList 网站上推荐相似动漫
  • 每个推荐都有投票数
  • API 返回投票数最高的推荐

这种基于用户投票的推荐比算法推荐更可靠,因为是真实用户认为这两部动漫相似。投票数越高,说明越多人认同这个推荐。

小结

推荐页的核心是双列网格布局和卡片宽度的计算。用 numColumns={2} 配合精确的宽度计算,可以实现整齐的网格效果。

推荐数用爱心图标显示,让用户知道这个推荐有多少人认同。点击卡片可以跳转到详情页,用 push 保留浏览历史。

这个页面的设计和新番页类似,但数据来源和展示内容不同。网格布局是展示多个动漫的常用方式,在应用中多次出现。

下一篇会讲统计页面,展示动漫的评分分布等数据。


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

Logo

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

更多推荐