在这里插入图片描述

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

评论页展示用户对动漫的评价和评论内容,是了解动漫口碑的重要渠道。这篇来讲评论页的实现,重点是多条评论的展开收起状态管理和评论卡片的布局设计。

功能设计

评论页需要展示以下内容:

  • 用户信息 - 头像、用户名、评论日期
  • 评分 - 用户给动漫的评分
  • 标签 - 评论的分类标签,如"推荐"、"混合"等
  • 评论内容 - 支持展开收起,因为评论通常很长
  • 列表展示 - 多条评论的列表

这个页面的特点是每条评论都可以独立展开收起,需要管理多个展开状态。

状态定义

const { animeId, title } = route.params;
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());

状态说明:

  • animeId - 动漫 ID,用于请求数据
  • title - 动漫标题,显示在 Header 副标题
  • reviews - 评论列表数据
  • loading - 加载状态
  • expandedIds - 已展开的评论 ID 集合

这里用 Set<number> 来存储已展开的评论 ID,而不是用数组或对象。Set 的优势是查找和删除操作都是 O(1) 时间复杂度,而且天然去重。

为什么不用单个 boolean 状态?因为页面上有多条评论,每条都可以独立展开收起。如果用单个 boolean,所有评论会同时展开或收起,这不是我们想要的效果。

数据加载

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

const loadReviews = async () => {
  try {
    const data = await getAnimeReviews(animeId);
    setReviews(data.data || []);
  } catch (error) {
    console.error('Load reviews error:', error);
  } finally {
    setLoading(false);
  }
};

加载逻辑:

  • 组件挂载时加载数据
  • data.data || [] 处理空数据
  • 错误时打印日志,不影响页面显示

评论数据通常不会太多,API 默认返回的数量足够展示,所以这里没有做分页。如果需要分页,可以参考剧集列表页的实现。

展开收起逻辑

const toggleExpand = (id: number) => {
  setExpandedIds(prev => {
    const newSet = new Set(prev);
    if (newSet.has(id)) {
      newSet.delete(id);
    } else {
      newSet.add(id);
    }
    return newSet;
  });
};

切换逻辑详解:

  • 用函数形式的 setState 获取最新状态
  • 创建新的 Set 对象(保持不可变性)
  • 如果 ID 已存在就删除(收起),否则添加(展开)
  • 返回新的 Set 触发重渲染

为什么要创建新的 Set 而不是直接修改?因为 React 通过引用比较来判断状态是否变化。如果直接修改原 Set,引用没变,React 不会重渲染。创建新 Set 可以确保状态变化被检测到。

这个模式在管理多个独立状态时很常用。比如多选列表、多个折叠面板等场景都可以用这种方式。

评论卡片渲染

const renderItem = ({ item }: { item: Review }) => {
  const isExpanded = expandedIds.has(item.mal_id);
  
  return (
    <View style={styles.reviewCard}>

渲染入口:

  • 从 expandedIds 中查找当前评论是否展开
  • Set.has() 方法返回 boolean,非常高效

每次渲染时都会检查 expandedIds.has(item.mal_id),这个操作是 O(1) 的,不会影响性能。

评论头部

      <View style={styles.reviewHeader}>
        <Image
          source={{ uri: item.user.images?.jpg?.image_url }}
          style={styles.avatar}
        />
        <View style={styles.userInfo}>
          <Text style={styles.username}>{item.user.username}</Text>
          <Text style={styles.date}>
            {new Date(item.date).toLocaleDateString()}
          </Text>
        </View>
        <ScoreDisplay score={item.score} size="sm" />
      </View>

头部布局:

  • 左侧是用户头像,圆形
  • 中间是用户名和日期,垂直排列
  • 右侧是评分组件

这种布局在社交类应用中很常见,用户信息在左,操作或状态在右。用户名和日期用不同的字号和颜色区分主次。

ScoreDisplay 是封装好的评分组件,这里用小号(sm)显示,不会太抢眼。

reviewHeader: {
  flexDirection: 'row',
  alignItems: 'center',
  marginBottom: Spacing.md,
},
avatar: {
  width: 40,
  height: 40,
  borderRadius: BorderRadius.full,
  backgroundColor: Colors.backgroundLight,
},

头部样式:

  • 横向布局,垂直居中
  • 头像 40x40,圆形
  • 设置背景色作为加载占位
userInfo: {
  flex: 1,
  marginLeft: Spacing.md,
},
username: {
  fontSize: FontSize.md,
  fontWeight: '600',
  color: Colors.text,
},
date: {
  fontSize: FontSize.xs,
  color: Colors.textMuted,
  marginTop: 2,
},

用户信息样式:

  • flex: 1 占据中间空间
  • 用户名加粗,主要颜色
  • 日期用最小字号,最淡颜色

标签区域

      {item.tags && item.tags.length > 0 && (
        <View style={styles.tags}>
          {item.tags.map((tag, index) => (
            <View key={index} style={styles.tag}>
              <Text style={styles.tagText}>{tag}</Text>
            </View>
          ))}
        </View>
      )}

标签显示:

  • 条件渲染,没有标签时不显示
  • 用 map 遍历渲染每个标签
  • 用 index 作为 key

评论标签通常是"Recommended"(推荐)、“Mixed Feelings”(混合感受)、“Not Recommended”(不推荐)等。这些标签可以帮助用户快速了解评论的倾向。

tags: {
  flexDirection: 'row',
  flexWrap: 'wrap',
  gap: Spacing.xs,
  marginBottom: Spacing.md,
},
tag: {
  backgroundColor: Colors.backgroundLight,
  paddingHorizontal: Spacing.sm,
  paddingVertical: 2,
  borderRadius: BorderRadius.sm,
},
tagText: {
  fontSize: FontSize.xs,
  color: Colors.textSecondary,
},

标签样式:

  • 横向排列,自动换行
  • 小圆角,灰色背景
  • 紧凑的内边距

标签用灰色背景而不是彩色,是为了不分散用户对评论内容的注意力。标签只是辅助信息,不应该太显眼。

评论内容

      <Text 
        style={styles.reviewText}
        numberOfLines={isExpanded ? undefined : 5}
      >
        {item.review}
      </Text>
      
      <Text 
        style={styles.expandButton}
        onPress={() => toggleExpand(item.mal_id)}
      >
        {isExpanded ? '收起' : '展开全文'}
      </Text>
    </View>
  );
};

评论内容和展开按钮:

  • numberOfLines 根据展开状态动态设置
  • 展开时 undefined 表示不限制行数
  • 收起时限制为 5 行
  • 按钮文字根据状态变化

评论内容通常很长,可能有几百甚至上千字。默认只显示 5 行可以让用户快速浏览多条评论,感兴趣的再点击展开。

这里用 Text 组件的 onPress 属性而不是 TouchableOpacity,是因为只需要简单的点击效果。Text 的 onPress 也支持点击,而且代码更简洁。

reviewText: {
  fontSize: FontSize.md,
  color: Colors.textSecondary,
  lineHeight: 22,
},
expandButton: {
  fontSize: FontSize.sm,
  color: Colors.primary,
  marginTop: Spacing.sm,
},

评论内容样式:

  • 正常字号,次要颜色
  • lineHeight: 22 增加行高,阅读更舒适
  • 展开按钮用主题色,表示可点击

页面结构

return (
  <View style={styles.container}>
    <Header
      title="用户评论"
      subtitle={title}
      showBack
      onBack={() => navigation.goBack()}
    />

Header 配置:

  • 标题显示"用户评论"
  • 副标题显示动漫名称
  • 显示返回按钮

条件渲染

    {loading ? (
      <Loading fullScreen />
    ) : reviews.length === 0 ? (
      <EmptyState icon="comment" title="暂无评论" />
    ) : (
      <FlatList
        data={reviews}
        renderItem={renderItem}
        keyExtractor={(item) => item.mal_id.toString()}
        contentContainerStyle={styles.list}
        showsVerticalScrollIndicator={false}
      />
    )}
  </View>
);

三种状态:

  • 加载中显示 Loading
  • 无评论显示空状态,用评论图标
  • 有评论显示列表

空状态用 comment 图标,和评论主题呼应。提示文字"暂无评论"简洁明了。

FlatList 配置

<FlatList
  data={reviews}
  renderItem={renderItem}
  keyExtractor={(item) => item.mal_id.toString()}
  contentContainerStyle={styles.list}
  showsVerticalScrollIndicator={false}
/>

配置说明:

  • 用评论 ID 作为 key
  • 隐藏滚动条
  • 没有分页加载,因为评论数量通常不多

这个页面没有用 onEndReached 做分页,因为 API 返回的评论数量通常在 20 条以内,一次性加载即可。如果评论很多,可以考虑加分页。

评论卡片样式

reviewCard: {
  backgroundColor: Colors.backgroundCard,
  borderRadius: BorderRadius.lg,
  padding: Spacing.lg,
  marginBottom: Spacing.md,
},

卡片样式:

  • 卡片背景色,圆角
  • 内边距让内容不贴边
  • 卡片之间有间距

每条评论用独立的卡片展示,视觉上分隔清晰。用户可以很容易区分不同的评论。

列表容器样式

list: {
  padding: Spacing.lg,
},

容器样式:

只设置了 padding,让列表内容和屏幕边缘有间距。

Set 数据结构的优势

用 Set 管理展开状态有几个优势:

  • 查找高效 - has() 方法是 O(1) 时间复杂度
  • 天然去重 - 不会出现重复 ID
  • 语义清晰 - Set 本身就表示"集合"的概念

如果用数组,查找需要遍历整个数组,是 O(n) 时间复杂度。虽然评论数量不多时差别不大,但用 Set 是更好的实践。

如果用对象 { [id]: boolean },也可以实现类似效果,但 Set 的 API 更简洁,不需要处理 true/false 值。

与其他展开收起的对比

项目中有多个地方用到了展开收起功能:

  • 动漫详情页简介 - 单个文本,用 boolean 状态
  • 角色详情页简介 - 单个文本,用 boolean 状态
  • 评论页 - 多条评论,用 Set 状态

单个文本用 boolean 就够了,多个独立的文本需要用 Set 或类似的数据结构。选择合适的数据结构可以让代码更简洁高效。

评论数据结构

评论的数据结构在 types 中定义:

interface Review {
  mal_id: number;
  user: {
    username: string;
    images: {
      jpg: {
        image_url: string;
      };
    };
  };
  score: number;
  date: string;
  review: string;
  tags: string[];
}

字段说明:

  • mal_id - 评论 ID
  • user - 用户信息,包含用户名和头像
  • score - 用户给的评分
  • date - 评论日期
  • review - 评论内容
  • tags - 评论标签数组

用户信息是嵌套结构,和其他 API 返回的数据结构类似。这种设计可以复用用户信息的类型定义。

小结

评论页的核心是多条评论的展开收起状态管理。用 Set 数据结构存储已展开的评论 ID,查找和修改都很高效。

评论卡片的布局采用常见的社交应用模式:头像在左,用户信息在中,评分在右。标签和评论内容依次排列,展开按钮在最下方。

评论内容默认只显示 5 行,用户可以点击展开查看全文。这种设计可以让用户快速浏览多条评论,找到感兴趣的再深入阅读。

下一篇会讲推荐页面,展示和当前动漫相似的其他作品。


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

Logo

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

更多推荐