RN for OpenHarmony英雄联盟助手App实战:主导航实现

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

经过前面 29 篇文章,我们已经实现了 App 的所有功能页面。但这些页面是分散的,需要一个导航系统把它们串联起来。这就是主导航的作用------它是整个 App 的骨架,决定了用户如何在不同页面之间切换。

这篇文章是本系列的最后一篇,我们来实现主导航系统,包括路由配置底部 Tab 栏顶部导航栏 、以及安全区域处理。这些组件共同构成了 App 的导航框架。

导航系统的整体架构

在开始看代码之前,先了解一下导航系统的整体架构:

复制代码
┌─────────────────────────────────────┐
│            SafeView                 │  ← 安全区域容器
│  ┌───────────────────────────────┐  │
│  │           Header              │  │  ← 顶部导航栏
│  ├───────────────────────────────┤  │
│  │                               │  │
│  │         PageComponent         │  │  ← 当前页面内容
│  │                               │  │
│  ├───────────────────────────────┤  │
│  │        BottomTabBar           │  │  ← 底部 Tab 栏
│  └───────────────────────────────┘  │
│  │        ToastContainer         │  │  ← 全局 Toast 提示
└─────────────────────────────────────┘

这种结构是移动 App 中最常见的导航模式:顶部标题栏 + 内容区域 + 底部 Tab 栏。几乎所有主流 App(微信、淘宝、抖音等)都采用这种布局。

为什么这种布局如此流行?

  1. 符合人体工学:底部 Tab 栏在拇指可触及范围内,单手操作方便
  2. 信息层级清晰:顶部显示当前位置,底部显示主要入口
  3. 用户习惯:用户已经形成了这种操作习惯,学习成本低

路由配置的设计

tsx 复制代码
// 主Tab页面
const mainTabs = ['Home', 'ChampionList', 'ItemList', 'RuneList', 'Settings'];

// 路由配置
const routes: Record<string, {component: React.FC; title: string}> = {
  Home: {component: HomePage, title: '首页'},
  News: {component: NewsPage, title: '版本资讯'},
  ChampionList: {component: ChampionListPage, title: '英雄图鉴'},
  ChampionDetail: {component: ChampionDetailPage, title: '英雄详情'},
  ChampionSkill: {component: ChampionSkillPage, title: '技能详情'},
  ChampionSkin: {component: ChampionSkinPage, title: '皮肤列表'},
  SkinDetail: {component: SkinDetailPage, title: '皮肤详情'},
  ChampionStory: {component: ChampionStoryPage, title: '背景故事'},
  ChampionTips: {component: ChampionTipsPage, title: '使用技巧'},
  ChampionFilter: {component: ChampionFilterPage, title: '英雄筛选'},
  ChampionCompare: {component: ChampionComparePage, title: '英雄对比'},
  ChampionSearch: {component: ChampionSearchPage, title: '英雄搜索'},
  ItemList: {component: ItemListPage, title: '装备大全'},
  ItemDetail: {component: ItemDetailPage, title: '装备详情'},
  ItemBuild: {component: ItemBuildPage, title: '合成树'},
  ItemFilter: {component: ItemFilterPage, title: '装备筛选'},
  ItemSearch: {component: ItemSearchPage, title: '装备搜索'},
  ItemCompare: {component: ItemComparePage, title: '装备对比'},
  RuneList: {component: RuneListPage, title: '符文系统'},
  RuneDetail: {component: RuneDetailPage, title: '符文详情'},
  RuneBuilder: {component: RuneBuilderPage, title: '符文配置'},
  RunePreset: {component: RunePresetPage, title: '符文预设'},
  SpellList: {component: SpellListPage, title: '召唤师技能'},
  SpellDetail: {component: SpellDetailPage, title: '技能详情'},
  Tools: {component: ToolsPage, title: '实用工具'},
  DamageCalc: {component: DamageCalcPage, title: '伤害计算'},
  CounterPick: {component: CounterPickPage, title: '克制关系'},
  Settings: {component: SettingsPage, title: '设置'},
  About: {component: AboutPage, title: '关于'},
};

路由配置的数据结构

每个路由是一个键值对,键是路由名称(如 'Home'),值是一个对象:

属性 类型 用途
component React.FC 要渲染的页面组件
title string 显示在顶部导航栏的标题

mainTabs 数组的作用

mainTabs 定义了哪些页面是主 Tab 页面。这个数组有两个重要用途:

  1. 控制返回按钮:主 Tab 页面不显示返回按钮,因为它们是顶级页面
  2. 底部 Tab 高亮:当用户在某个主 Tab 页面时,对应的 Tab 会高亮

路由命名的规范

观察路由名称,可以发现一些命名规律:

  • 列表页XxxList(如 ChampionList、ItemList)
  • 详情页XxxDetail(如 ChampionDetail、ItemDetail)
  • 功能页:动词或名词(如 ChampionFilter、ChampionCompare)

这种命名规范让代码自文档化,看到路由名称就知道是什么页面。

MainPage 组件的实现

tsx 复制代码
import React, {useMemo} from 'react';
import {View, StyleSheet} from 'react-native';
import {useTheme} from '../context/ThemeContext';
import {useNavigation} from '../context/NavigationContext';
import {Header, SafeView, BottomTabBar} from '../components/layout';
import {ToastContainer} from '../components/common/Toast';

export function MainPage() {
  const {colors} = useTheme();
  const {currentRoute} = useNavigation();
  const route = routes[currentRoute] || routes.Home;
  const PageComponent = route.component;
  const showBack = !mainTabs.includes(currentRoute);

  const styles = useMemo(() => StyleSheet.create({
    content: {flex: 1, backgroundColor: colors.background},
  }), [colors]);

  return (
    <SafeView>
      <Header title={route.title} showBack={showBack} />
      <View style={styles.content}>
        <PageComponent />
      </View>
      <BottomTabBar />
      <ToastContainer />
    </SafeView>
  );
}

核心逻辑逐行分析

tsx 复制代码
const route = routes[currentRoute] || routes.Home;

根据当前路由名称从配置中获取对应的路由信息。|| routes.Home降级处理:如果找不到对应路由(比如路由名称拼写错误),就显示首页,避免白屏崩溃。

tsx 复制代码
const PageComponent = route.component;

把组件赋值给一个大写开头的变量。在 React 中,组件名必须大写开头,这样 JSX 才能正确识别它是组件而不是 HTML 标签。

tsx 复制代码
const showBack = !mainTabs.includes(currentRoute);

判断是否显示返回按钮。逻辑是:如果当前路由不在 mainTabs 数组中,就显示返回按钮。换句话说,只有子页面才显示返回按钮。

组件渲染结构

tsx 复制代码
<SafeView>
  <Header title={route.title} showBack={showBack} />
  <View style={styles.content}>
    <PageComponent />
  </View>
  <BottomTabBar />
  <ToastContainer />
</SafeView>

这个结构从外到内:

  1. SafeView:最外层,处理安全区域和状态栏
  2. Header:顶部导航栏,固定在顶部
  3. content View :内容区域,flex: 1 占据剩余空间
  4. PageComponent:当前页面的实际内容
  5. BottomTabBar:底部 Tab 栏,固定在底部
  6. ToastContainer:Toast 提示容器,浮动在最上层

底部 Tab 栏的实现

tsx 复制代码
const tabs = [
  {key: 'Home', label: '首页', icon: '🏠'},
  {key: 'ChampionList', label: '英雄', icon: '⚔️'},
  {key: 'ItemList', label: '装备', icon: '🛡️'},
  {key: 'RuneList', label: '符文', icon: '✨'},
  {key: 'Settings', label: '设置', icon: '⚙️'},
];

Tab 配置的设计

属性 类型 说明
key string 路由名称,点击时用于导航
label string 显示的文字标签
icon string Emoji 图标

为什么选择 5 个 Tab?

5 个是移动端 Tab 栏的黄金数量

  • 太少(2-3 个):浪费屏幕空间,功能入口不够
  • 太多(6 个以上):每个 Tab 太窄,图标和文字挤在一起,难以点击
  • 5 个:每个 Tab 宽度适中,图标清晰,文字可读

图标的选择

每个图标都和功能语义相关

  • 🏠 首页:房子代表"家",是 App 的起点
  • ⚔️ 英雄:剑代表战斗,英雄是战斗的主体
  • 🛡️ 装备:盾牌是典型的装备
  • ✨ 符文:闪光代表魔法效果
  • ⚙️ 设置:齿轮是设置的通用图标

智能高亮的实现

tsx 复制代码
const getActiveTab = (route: string): string => {
  if (route.startsWith('Champion') || route === 'SkinDetail') return 'ChampionList';
  if (route.startsWith('Item')) return 'ItemList';
  if (route.startsWith('Rune')) return 'RuneList';
  if (route.startsWith('Spell') || route.startsWith('Tool') || route === 'DamageCalc' || route === 'CounterPick') return 'Home';
  if (route === 'Settings' || route === 'About') return 'Settings';
  return 'Home';
};

这个函数实现了智能高亮功能:即使用户在子页面,底部 Tab 栏也能正确高亮对应的主 Tab。

匹配规则详解

当前路由 高亮的 Tab 匹配规则
ChampionDetail 英雄 以 'Champion' 开头
ChampionSkill 英雄 以 'Champion' 开头
SkinDetail 英雄 特殊处理(皮肤属于英雄)
ItemBuild 装备 以 'Item' 开头
RuneBuilder 符文 以 'Rune' 开头
SpellList 首页 召唤师技能从首页进入
DamageCalc 首页 工具从首页进入
About 设置 关于从设置进入

为什么需要智能高亮?

如果没有智能高亮,用户进入子页面后,底部 Tab 栏会失去高亮 ,用户不知道自己在哪个模块。智能高亮让用户始终清楚自己的位置,这是良好导航体验的关键。

Tab 栏的渲染逻辑

tsx 复制代码
export function BottomTabBar() {
  const {colors} = useTheme();
  const {currentRoute, navigate} = useNavigation();
  const activeTab = getActiveTab(currentRoute);

  const styles = useMemo(() => StyleSheet.create({
    container: {
      flexDirection: 'row',
      backgroundColor: colors.backgroundLight,
      borderTopWidth: 1,
      borderTopColor: colors.border,
      paddingBottom: 20,
      paddingTop: 8,
    },
    tab: {
      flex: 1, 
      alignItems: 'center', 
      justifyContent: 'center', 
      paddingVertical: 4, 
      position: 'relative'
    },
    icon: {fontSize: 22, marginBottom: 2},
    iconActive: {transform: [{scale: 1.1}]},
    label: {fontSize: 11, color: colors.textMuted},
    labelActive: {color: colors.textGold, fontWeight: '600'},
    indicator: {
      position: 'absolute', 
      top: 0, 
      width: 20, 
      height: 3, 
      backgroundColor: colors.primary, 
      borderRadius: 2
    },
  }), [colors]);

  return (
    <View style={styles.container}>
      {tabs.map(tab => {
        const isActive = activeTab === tab.key;
        return (
          <TouchableOpacity
            key={tab.key}
            style={styles.tab}
            onPress={() => navigate(tab.key)}
            activeOpacity={0.7}>
            <Text style={[styles.icon, isActive && styles.iconActive]}>{tab.icon}</Text>
            <Text style={[styles.label, isActive && styles.labelActive]}>{tab.label}</Text>
            {isActive && <View style={styles.indicator} />}
          </TouchableOpacity>
        );
      })}
    </View>
  );
}

选中状态的三重视觉反馈

选中的 Tab 有三个视觉变化,让用户一眼就能看出哪个是当前选中的:

  1. 图标放大transform: [{scale: 1.1}],放大 10%,微妙但可感知
  2. 文字变色加粗:从灰色变成金色,并加粗(fontWeight: '600')
  3. 顶部指示条:显示一个 20px 宽、3px 高的金色小横条

paddingBottom: 20 的作用

这个较大的底部内边距是为了适配全面屏手机。在 iPhone X 及以后的机型上,底部有一个手势操作区域(Home Indicator)。如果 Tab 栏太贴近底部,用户点击时可能会误触发系统的返回主屏手势。

flex: 1 的均分效果

每个 Tab 都设置了 flex: 1,这意味着它们会平均分配容器的宽度。5 个 Tab 各占 20%,无论屏幕多宽都能保持均匀分布。

顶部导航栏的实现

tsx 复制代码
interface HeaderProps {
  title: string;
  showBack?: boolean;
  onMenuPress?: () => void;
  rightElement?: React.ReactNode;
}

export function Header({title, showBack = false, onMenuPress, rightElement}: HeaderProps) {
  const {colors} = useTheme();
  const {goBack, canGoBack} = useNavigation();

  const styles = useMemo(() => StyleSheet.create({
    container: {
      height: 56,
      flexDirection: 'row',
      alignItems: 'center',
      justifyContent: 'space-between',
      paddingHorizontal: 8,
      backgroundColor: colors.backgroundLight,
      borderBottomWidth: 1,
      borderBottomColor: colors.border,
    },
    left: {width: 48, alignItems: 'flex-start'},
    right: {width: 48, alignItems: 'flex-end'},
    title: {
      flex: 1, 
      fontSize: 18, 
      fontWeight: '600', 
      color: colors.textPrimary, 
      textAlign: 'center'
    },
    button: {width: 40, height: 40, alignItems: 'center', justifyContent: 'center'},
    backIcon: {fontSize: 24, color: colors.textPrimary},
    menuIcon: {fontSize: 22, color: colors.textPrimary},
    placeholder: {width: 40},
  }), [colors]);

Props 的灵活设计

Prop 类型 默认值 用途
title string 必填 标题文字
showBack boolean false 是否显示返回按钮
onMenuPress function undefined 菜单按钮点击回调
rightElement ReactNode undefined 右侧自定义元素

这种设计让 Header 组件高度可复用

  • 主 Tab 页面:只传 title
  • 子页面:传 title + showBack
  • 需要右侧按钮的页面:传 title + rightElement
  • 需要菜单的页面:传 title + onMenuPress

经典的三栏布局

tsx 复制代码
container: {
  flexDirection: 'row',
  alignItems: 'center',
  justifyContent: 'space-between',
}
left: {width: 48}
title: {flex: 1, textAlign: 'center'}
right: {width: 48}

这是移动端导航栏的经典布局

  • 左侧固定 48px,放返回按钮或菜单按钮
  • 中间 flex: 1,放标题,自动占据剩余空间
  • 右侧固定 48px,放操作按钮或留空

为什么左右两侧要固定宽度?

如果左右宽度不固定,标题就无法真正居中。比如左侧有返回按钮(40px),右侧没有按钮,标题会偏向右边。固定左右宽度后,标题始终在正中间

左侧按钮的条件渲染

tsx 复制代码
  const handleBack = () => {
    if (canGoBack()) goBack();
  };

  return (
    <View style={styles.container}>
      <View style={styles.left}>
        {showBack && canGoBack() ? (
          <TouchableOpacity onPress={handleBack} style={styles.button}>
            <Text style={styles.backIcon}>←</Text>
          </TouchableOpacity>
        ) : onMenuPress ? (
          <TouchableOpacity onPress={onMenuPress} style={styles.button}>
            <Text style={styles.menuIcon}>☰</Text>
          </TouchableOpacity>
        ) : (
          <View style={styles.placeholder} />
        )}
      </View>
      <Text style={styles.title} numberOfLines={1}>{title}</Text>
      <View style={styles.right}>
        {rightElement || <View style={styles.placeholder} />}
      </View>
    </View>
  );
}

条件渲染的优先级

左侧区域的渲染遵循优先级规则

  1. 最高优先级 :如果 showBack 为 true 且可以返回 → 显示返回按钮
  2. 次优先级 :如果有 onMenuPress 回调 → 显示菜单按钮
  3. 默认 :都不满足 → 显示占位符(空的 View)

canGoBack() 的双重检查

tsx 复制代码
{showBack && canGoBack() ? (...) : (...)}

即使 showBack 为 true,也要检查 canGoBack()。这是因为用户可能通过深链接直接进入子页面,此时导航栈只有一个页面,无法返回。双重检查可以避免点击返回按钮无反应的尴尬情况。

占位符的必要性

tsx 复制代码
<View style={styles.placeholder} />

占位符是一个空的 View ,宽度和按钮相同(40px)。它的作用是保持布局平衡:即使左侧没有按钮,也要占据相同的空间,这样标题才能真正居中。

安全区域的处理

tsx 复制代码
import React from 'react';
import {View, StyleSheet, StatusBar, Platform} from 'react-native';
import {useTheme} from '../../context/ThemeContext';

interface SafeViewProps {
  children: React.ReactNode;
  backgroundColor?: string;
}

export function SafeView({children, backgroundColor}: SafeViewProps) {
  const {colors, isDark} = useTheme();
  const bgColor = backgroundColor || colors.background;

  return (
    <View style={[styles.container, {backgroundColor: bgColor}]}>
      <StatusBar
        barStyle={isDark ? 'light-content' : 'dark-content'}
        backgroundColor={colors.backgroundLight}
      />
      {Platform.OS === 'ios' && (
        <View style={[styles.statusBar, {backgroundColor: bgColor}]} />
      )}
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {flex: 1},
  statusBar: {height: 44},
});

SafeView 解决的问题

现代手机有各种"异形屏":刘海屏、挖孔屏、曲面屏等。如果不处理安全区域,内容可能会被刘海遮挡,或者延伸到圆角区域被裁切。

StatusBar 的配置

tsx 复制代码
<StatusBar
  barStyle={isDark ? 'light-content' : 'dark-content'}
  backgroundColor={colors.backgroundLight}
/>
  • barStyle:状态栏文字颜色。深色主题用白色文字('light-content'),浅色主题用黑色文字('dark-content')
  • backgroundColor:状态栏背景色(仅 Android 有效)

iOS 刘海屏适配

tsx 复制代码
{Platform.OS === 'ios' && (
  <View style={[styles.statusBar, {backgroundColor: bgColor}]} />
)}

在 iOS 上添加一个 44px 高的占位区域。这个高度覆盖了 iPhone X 及以后机型的刘海区域。

为什么只在 iOS 上添加?

Android 的状态栏处理方式不同,通过 StatusBar 组件的 backgroundColor 属性就能控制。而 iOS 的状态栏是透明的,需要手动添加占位区域。

导航流程的完整分析

让我们追踪一下用户点击 Tab 后的完整数据流

复制代码
1. 用户点击 "英雄" Tab
   ↓
2. BottomTabBar 的 onPress 触发
   ↓
3. 调用 navigate('ChampionList')
   ↓
4. NavigationContext 更新状态:
   setHistory(prev => [...prev, {name: 'ChampionList', params: {}}])
   ↓
5. currentRoute 变为 'ChampionList'
   ↓
6. MainPage 组件重新渲染
   ↓
7. route = routes['ChampionList'] 
      = {component: ChampionListPage, title: '英雄图鉴'}
   ↓
8. showBack = !mainTabs.includes('ChampionList') = false
   ↓
9. Header 渲染:title="英雄图鉴",无返回按钮
   ↓
10. PageComponent = ChampionListPage,渲染英雄列表
   ↓
11. BottomTabBar 重新渲染:
    activeTab = getActiveTab('ChampionList') = 'ChampionList'
    "英雄" Tab 高亮

整个流程是声明式 的:我们只需要更新状态(currentRoute),UI 会自动响应更新。这就是 React 的核心理念:UI = f(State)

扩展:添加页面切换动画

当前实现没有页面切换动画,页面是瞬间切换的。可以添加简单的淡入淡出效果:

tsx 复制代码
import {Animated} from 'react-native';

export function MainPage() {
  const fadeAnim = useRef(new Animated.Value(1)).current;
  const prevRoute = useRef(currentRoute);

  useEffect(() => {
    if (prevRoute.current !== currentRoute) {
      // 先淡出
      Animated.timing(fadeAnim, {
        toValue: 0,
        duration: 100,
        useNativeDriver: true,
      }).start(() => {
        prevRoute.current = currentRoute;
        // 再淡入
        Animated.timing(fadeAnim, {
          toValue: 1,
          duration: 200,
          useNativeDriver: true,
        }).start();
      });
    }
  }, [currentRoute]);

  return (
    <SafeView>
      <Header ... />
      <Animated.View style={[styles.content, {opacity: fadeAnim}]}>
        <PageComponent />
      </Animated.View>
      <BottomTabBar />
    </SafeView>
  );
}

扩展:添加手势返回

iOS 用户习惯从屏幕左边缘滑动返回。可以用 PanResponder 实现:

tsx 复制代码
import {PanResponder} from 'react-native';

const panResponder = useRef(
  PanResponder.create({
    onMoveShouldSetPanResponder: (evt, gestureState) => {
      // 只在左边缘开始、向右滑动时响应
      return evt.nativeEvent.pageX < 30 && gestureState.dx > 10;
    },
    onPanResponderRelease: (evt, gestureState) => {
      // 滑动距离超过 100 时触发返回
      if (gestureState.dx > 100 && canGoBack()) {
        goBack();
      }
    },
  })
).current;

return (
  <View {...panResponder.panHandlers}>
    {/* 页面内容 */}
  </View>
);

系列总结

到这里,我们完成了整个 LOL 助手 App 的开发!让我们回顾一下这 30 篇文章涵盖的内容:

基础页面(1-9):首页、版本资讯、英雄图鉴、英雄详情、技能详情、皮肤列表、皮肤详情、背景故事、使用技巧

英雄功能(10-12):英雄筛选、英雄对比、英雄搜索

装备系统(13-18):装备大全、装备详情、合成树、装备筛选、装备搜索、装备对比

符文系统(19-22):符文系统、符文详情、符文配置、符文预设

召唤师技能(23-24):召唤师技能列表、召唤师技能详情

实用工具(25-27):实用工具、伤害计算、克制关系

设置与关于(28-29):设置、关于

导航系统(30):主导航

通过这个项目,我们学习了 React Native 开发的核心技能

  • 组件设计:如何拆分和组织组件
  • 状态管理:Context API 的使用
  • 导航系统:自定义导航的实现
  • 主题切换:深色/浅色主题的支持
  • API 调用:网络请求和数据处理
  • 列表渲染:FlatList 和 ScrollView 的使用
  • 表单处理:受控组件和输入验证
  • 样式设计:Flexbox 布局和动态样式

希望这个系列对你的 React Native 学习之旅有所帮助!


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

相关推荐
Filotimo_2 小时前
N+1查询问题
数据库·oracle
fenglllle3 小时前
spring-data-jpa saveall慢的原因
数据库·spring·hibernate
DarkAthena4 小时前
【GaussDB】执行索引跳扫时如果遇到该索引正在执行autovacuum,可能会导致数据查询不到
数据库·gaussdb
短剑重铸之日4 小时前
《7天学会Redis》Day 5 - Redis Cluster集群架构
数据库·redis·后端·缓存·架构·cluster
007php0074 小时前
mySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据
数据库·redis·git·mysql·面试·职场和发展·php
lkbhua莱克瓦244 小时前
进阶-存储过程3-存储函数
java·数据库·sql·mysql·数据库优化·视图
老邓计算机毕设5 小时前
SSM心理健康系统84459(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·ssm 框架·心理健康系统·在线咨询
碎像5 小时前
10分钟搞定 MySQL 通过Binlog 数据备份和恢复
数据库·mysql