在这里插入图片描述

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

每个英雄都有自己的故事。盖伦是德玛西亚的守护者,亚索是被冤枉的剑客,金克丝是疯狂的爆破专家……这些背景故事让英雄不再只是一堆技能数值,而是有血有肉的角色。

背景故事页要做的就是把这些故事以最佳的阅读体验呈现出来。这篇文章我们来实现这个页面,重点聊聊长文本的排版、阅读体验优化、以及 ImageBackground 的进阶用法。

阅读体验的设计考量

在开始写代码之前,先想想什么样的阅读体验是好的:

  1. 舒适的字号和行高:太小看不清,太大一屏显示不了几行
  2. 合适的行宽:一行太长眼睛追踪困难,太短又要频繁换行
  3. 足够的对比度:文字和背景要有足够的对比度
  4. 视觉层次:标题、正文要有明显区分

带着这些考量,我们来实现页面。


页面结构

import React, {useEffect, useState} from 'react';
import {
  View,
  Text,
  ScrollView,
  ImageBackground,
  StyleSheet,
  Dimensions,
} from 'react-native';
import {colors} from '../../styles/colors';
import {useApp} from '../../context/AppContext';
import {useNavigation} from '../../context/NavigationContext';
import {championApi} from '../../api';
import {getChampionSplashUrl} from '../../utils/image';
import {Loading} from '../../components/common';
import type {ChampionDetail} from '../../models/Champion';

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

导入的组件说明:

  • ScrollView:故事内容可能很长,需要滚动查看
  • ImageBackground:在头部显示英雄原画作为背景
  • Dimensions:获取屏幕宽度,用于设置头部图片尺寸
export function ChampionStoryPage() {
  const {state} = useApp();
  const {params} = useNavigation();
  const [champion, setChampion] = useState<ChampionDetail | null>(null);
  const [loading, setLoading] = useState(true);

  const championId = params.championId as string;

状态设计:

这个页面的状态和之前几个页面一样简单:

  • champion:存储英雄详情数据
  • loading:控制加载状态

背景故事在 champion.lore 字段中,是一个纯文本字符串。


数据加载

  useEffect(() => {
    async function loadDetail() {
      if (!state.version || !championId) return;

      setLoading(true);
      const detail = await championApi.getChampionDetail(
        state.version,
        championId,
      );
      setChampion(detail);
      setLoading(false);
    }

    loadDetail();
  }, [state.version, championId]);

为什么不从上一页传递故事内容?

背景故事通常有几百甚至上千字,通过路由参数传递这么长的文本不太合适。而且英雄详情数据会被缓存,重新请求的开销很小。

另外,独立加载数据让这个页面可以作为独立入口使用(比如从搜索结果直接跳转)。


头部设计

头部使用英雄原画作为背景,叠加英雄名称和称号:

  return (
    <ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
      {/* 头部 */}
      <ImageBackground
        source={{uri: getChampionSplashUrl(championId, 0)}}
        style={styles.header}>
        <View style={styles.headerOverlay}>
          <Text style={styles.name}>{champion.name}</Text>
          <Text style={styles.title}>{champion.title}</Text>
        </View>
      </ImageBackground>

ImageBackground 的结构:

ImageBackground 本质上是一个带背景图的 View,它的子元素会显示在图片上方。我们在里面放了一个半透明遮罩(headerOverlay),遮罩里是文字。

这种"图片 + 遮罩 + 文字"的组合是很常见的设计模式,能确保文字在任何背景图上都清晰可读。

为什么用默认皮肤的原画?

getChampionSplashUrl(championId, 0) 中的 0 表示默认皮肤。背景故事讲的是英雄的"本体",用默认皮肤最合适。

如果用其他皮肤(比如"星之守护者"系列),视觉上会和故事内容不搭。


头部样式详解

  header: {
    width: SCREEN_WIDTH,
    height: 200,
    justifyContent: 'flex-end',
  },
  headerOverlay: {
    backgroundColor: 'rgba(0, 0, 0, 0.6)',
    padding: 16,
  },
  name: {
    fontSize: 24,
    fontWeight: 'bold',
    color: colors.textGold,
    marginBottom: 4,
  },
  title: {
    fontSize: 16,
    color: colors.textSecondary,
  },

头部高度的选择:

height: 200 是一个平衡点:

  • 太矮(如 120):原画显示不完整,视觉冲击力不够
  • 太高(如 300):占用太多屏幕空间,故事内容要滚动很多才能看到

200px 大约是屏幕高度的 1/4,既能展示原画的精华部分,又不会喧宾夺主。

justifyContent: ‘flex-end’ 的作用:

让遮罩和文字靠底部显示。这样文字会出现在原画的下半部分,通常是英雄身体的位置,不会遮挡英雄的脸。

遮罩透明度的选择:

rgba(0, 0, 0, 0.6) 是 60% 透明度的黑色。这个值是经过测试的:

  • 太透明(如 0.3):文字在浅色背景上看不清
  • 太不透明(如 0.8):原画几乎看不见,失去了背景图的意义

故事内容区域

      {/* 故事内容 */}
      <View style={styles.content}>
        <Text style={styles.sectionTitle}>📜 背景故事</Text>
        <View style={styles.storyCard}>
          <Text style={styles.storyText}>{champion.lore}</Text>
        </View>
      </View>

      <View style={styles.bottomSpace} />
    </ScrollView>
  );

Emoji 作为图标:

📜 是一个卷轴的 Emoji,和"背景故事"的主题很搭。在不引入图标库的情况下,Emoji 是一个简单有效的方案。

storyCard 的设计:

把故事内容放在一个卡片里,和页面背景区分开。卡片有自己的背景色、圆角和边框,形成一个独立的阅读区域。


故事文本样式

这是整个页面最重要的部分——文本的排版直接影响阅读体验:

  storyText: {
    fontSize: 15,
    color: colors.textSecondary,
    lineHeight: 26,
    textAlign: 'justify',
  },

字号选择:

fontSize: 15 是一个适中的大小:

  • 14px:稍微有点小,长时间阅读会累
  • 15px:刚好,大多数用户都能舒适阅读
  • 16px:也可以,但一行能显示的字数会减少

如果你的目标用户年龄偏大,可以考虑用 16px 或更大。

行高设置:

lineHeight: 26 配合 fontSize: 15,行高是字号的 1.73 倍。

为什么要这么大的行高?因为中文阅读和英文不同:

  • 中文没有单词间的空格,一行文字是连续的
  • 较大的行高能让眼睛更容易追踪到下一行
  • 也让段落看起来更"透气",不会有压迫感

两端对齐:

textAlign: 'justify' 让文字两端对齐。这是中文排版的常见做法,让每行的左右边缘都整齐,视觉上更美观。

注意:两端对齐在英文中可能会导致单词间距不均匀,但在中文中效果很好。

文字颜色:

textSecondary 而不是 textPrimary。纯白色文字在深色背景上对比度太高,长时间阅读会刺眼。稍微灰一点的颜色更舒适。


内容区域样式

  content: {
    padding: 16,
  },
  sectionTitle: {
    fontSize: 18,
    fontWeight: '600',
    color: colors.textPrimary,
    marginBottom: 12,
  },
  storyCard: {
    backgroundColor: colors.backgroundCard,
    borderRadius: 8,
    padding: 16,
    borderWidth: 1,
    borderColor: colors.border,
  },

卡片内边距:

padding: 16 让文字不会贴着卡片边缘。这个间距和页面的整体 padding 一致,保持视觉统一。

标题和正文的区分:

标题用 textPrimary(更亮),正文用 textSecondary(稍暗)。这种对比让用户一眼就能区分标题和内容。


长文本的处理

有些英雄的背景故事非常长,可能有上千字。我们需要确保长文本能正确显示:

<Text style={styles.storyText}>{champion.lore}</Text>

React Native 的 Text 组件特性:

Text 组件会自动处理文本换行,不需要我们手动处理。只要设置好宽度(通过父容器的 padding 间接设置),文本就会在合适的位置换行。

段落分隔:

拳头 API 返回的故事文本通常是一整段,没有分段。如果你想要更好的阅读体验,可以尝试按句号分段:

const paragraphs = champion.lore.split('。').filter(p => p.trim());

{paragraphs.map((p, i) => (
  <Text key={i} style={styles.storyText}>
    {p}。
  </Text>
))}

不过这种处理可能会破坏原有的语义,需要谨慎使用。


阅读进度指示

如果故事很长,用户可能想知道自己读到哪里了。可以添加一个阅读进度条:

const [scrollProgress, setScrollProgress] = useState(0);

<ScrollView
  onScroll={(event) => {
    const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent;
    const progress = contentOffset.y / (contentSize.height - layoutMeasurement.height);
    setScrollProgress(Math.min(Math.max(progress, 0), 1));
  }}
  scrollEventThrottle={16}>
  {/* ... */}
</ScrollView>

<View style={[styles.progressBar, {width: `${scrollProgress * 100}%`}]} />

scrollEventThrottle 的作用:

这个属性控制 onScroll 事件的触发频率。16 表示每 16 毫秒最多触发一次(约 60fps)。值越小越流畅,但也越耗性能。

为什么当前没有实现?

大多数英雄的背景故事在 2-3 屏内就能读完,进度条的意义不大。如果你的内容更长(比如小说阅读 App),进度条会更有价值。


字体大小调节

有些用户可能想要更大或更小的字体。可以添加字体大小调节功能:

const [fontSize, setFontSize] = useState(15);

<View style={styles.fontSizeControl}>
  <TouchableOpacity onPress={() => setFontSize(f => Math.max(f - 1, 12))}>
    <Text>A-</Text>
  </TouchableOpacity>
  <TouchableOpacity onPress={() => setFontSize(f => Math.min(f + 1, 20))}>
    <Text>A+</Text>
  </TouchableOpacity>
</View>

<Text style={[styles.storyText, {fontSize, lineHeight: fontSize * 1.73}]}>
  {champion.lore}
</Text>

字号范围限制:

Math.max(f - 1, 12) 确保字号不会小于 12px(太小看不清)
Math.min(f + 1, 20) 确保字号不会大于 20px(太大一屏显示不了几行)

行高联动:

字号变化时,行高也要按比例变化,保持 1.73 倍的比例。


夜间模式适配

背景故事页是阅读密集型页面,夜间模式尤其重要。我们的主题系统已经支持深色/浅色切换,这个页面会自动适配:

// 深色模式
storyText: {
  color: colors.textSecondary,  // 浅灰色文字
}
storyCard: {
  backgroundColor: colors.backgroundCard,  // 深灰色背景
}

// 浅色模式
storyText: {
  color: colors.textSecondary,  // 深灰色文字
}
storyCard: {
  backgroundColor: colors.backgroundCard,  // 浅灰色背景
}

颜色变量的好处:

因为我们用的是颜色变量(colors.textSecondary)而不是硬编码的颜色值,主题切换时颜色会自动变化,不需要额外处理。


完整样式回顾

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
  },
  header: {
    width: SCREEN_WIDTH,
    height: 200,
    justifyContent: 'flex-end',
  },
  headerOverlay: {
    backgroundColor: 'rgba(0, 0, 0, 0.6)',
    padding: 16,
  },
  name: {
    fontSize: 24,
    fontWeight: 'bold',
    color: colors.textGold,
    marginBottom: 4,
  },
  title: {
    fontSize: 16,
    color: colors.textSecondary,
  },
  content: {
    padding: 16,
  },
  sectionTitle: {
    fontSize: 18,
    fontWeight: '600',
    color: colors.textPrimary,
    marginBottom: 12,
  },
  storyCard: {
    backgroundColor: colors.backgroundCard,
    borderRadius: 8,
    padding: 16,
    borderWidth: 1,
    borderColor: colors.border,
  },
  storyText: {
    fontSize: 15,
    color: colors.textSecondary,
    lineHeight: 26,
    textAlign: 'justify',
  },
  bottomSpace: {
    height: 20,
  },
});

小结

背景故事页的核心是提供舒适的阅读体验。我们通过以下方式实现:

  1. 头部设计:用英雄原画作为背景,配合半透明遮罩显示英雄信息
  2. 文本排版:选择合适的字号(15px)和行高(26px),使用两端对齐
  3. 卡片容器:把故事内容放在卡片里,形成独立的阅读区域
  4. 颜色对比:使用稍暗的文字颜色,减少长时间阅读的视觉疲劳

下一篇我们来实现使用技巧页,展示英雄的使用建议和对抗技巧。


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

Logo

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

更多推荐