深入学习React Native之路由导航

  路由是前端项目一个重要的组成部分,因为我们项目都是由多个页面组成,即使单页面项目也会有路由,多个页面之间跳转就是通过路由或者导航器来实现的。在RN 0.44之前的版本,我们可以直接使用官方提供的Navigator组件来实现跳转;从0.44版本开始,Navigator被官方从RN的核心组件库中剥离出来,主推的一个导航库就是React Navigation,它性能也很接近原生,我们今天就来学习下它的用法。

路由

  React Navigation库每个版本的改动还是挺大的,比如3.x创建堆栈导航和创建选项卡导航都是直接在react-navigation库中导出create函数,而4.x中堆栈路由是从react-navigation-stack这个库导出,5.x版本库名又改成了@react-navigation/stack,6.x版本又双叒叕改成@react-navigation/native-stack,因此对新手及其不友好,很容易让人看了头大。

人都看傻了

  不过好在导航方式主要是三种:堆栈导航(StackNavigator)、选项卡导航(TabNavigator)和抽屉导航(DrawerNavigator),而且导航方式、传参都大差不差,因此本文主要以目前最新的6.x为例。

堆栈导航

  堆栈导航是比较常见的导航方式,为应用程序在不同屏幕之间转换提供导航和管理的方式;其有些类似于web浏览器处理导航状态的方式。首先我们安装一些依赖:

1
2
3
4
5
6
7
8
# 安装导航的核心库
yarn add @react-navigation/native

# 安装导航的外部依赖库
yarn add react-native-screens react-native-safe-area-context

# 安装堆栈导航的主要库
yarn add @react-navigation/native-stack

  要在项目里使用导航,我们首先要在项目的根组件创建一个路由导航容器,将我们的路由都包裹(一般是在App.js中),有点类似于Vue的<router-view />

1
2
3
4
5
6
7
8
9
10
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';

export default function App() {
return (
<NavigationContainer>
{/* 导航组件 */}
</NavigationContainer>
);
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们导出createNativeStackNavigator函数,用于配置堆栈路由的管理;它返回了包含两个组件的对象:Screen和Navigator,他们都是配置导航器所需的React组件,其中Screen组件是一个高阶组件,会增强props;在使用的页面中,会携带navigation对象和route对象,下面我们会介绍这两个对象的用法。

  我们新建一个StackRouter.js,将所有的堆栈导航配置统一在这个文件配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// StackRouter.js
import {createNativeStackNavigator} from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
function HomeScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Home Screen</Text>
</View>
);
}
export default function StackRouter() {
return (
<Stack.Navigator>
<Stack.Screen
name="Home"
component={HomeScreen}
/>
</Stack.Navigator>
);
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在根组件中引入我们的堆栈导航组件即可:

1
2
3
4
5
6
7
8
9
// App.js
import StackRouter from './src/StackRouter';
export default function App() {
return (
<NavigationContainer>
<StackRouter></StackRouter>
</NavigationContainer>
);
}

堆栈导航

  我们的程序很多时候都不止一个页面,我们可以在堆栈导航中继续加入其他的列表页、详情页等等;initialRouteName配置初始化的路由,可以设置成非第一个Screen页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// StackRouter.js
export default function StackRouter() {
return (
<Stack.Navigator initialRouteName="Home">
<Stack.Screen
name="Home"
component={HomeScreen}
/>
<Stack.Screen
name="List"
component={ListScreen}
/>
<Stack.Screen
name="Detail"
component={DetailScreen}
/>
</Stack.Navigator>
);
}

  我们可以在每个路由上通过options配置不同参数,比如标题、导航栏颜色等:

1
2
3
4
5
6
7
8
9
10
11
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: '首页',
headerStyle: {
height: 80,
backgroundColor: '#2196F3',
},
}}
/>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  有时候,我们想要给一个页面传入额外的参数,我们可以把页面组件放到上下文中包裹,并传入props:

1
2
3
<Stack.Screen name="Home">
{(props) => <HomeScreen {...props} extraData={someData} />}
</Stack.Screen>

  上面的用法在配置同一个页面不同路径时会很有用;比如我们的新建和编辑页面可以做成一个页面,配置不同路由通过传入额外的参数对两个页面进行区分。

路由跳转

  在不同页面间,我们需要进行路由跳转;我们上面说过,在所有的页面组件中,都会携带一个navigation对象,它是react-navigation注入的路由对象,它上面有很多的函数,可以进行不同形式的跳转。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

如果我们跳转到未定义的路由,在开发版本中会报错,而在生产环境中不会发生任何事,

  我们调用navigation.navigate()函数来跳转,直接传入Stack.Navigator中定义路由名name:

1
2
3
4
<Button 
onPress={() => this.props.navigation.navigate('List')}
title="Go List">
</Button>

navigate跳转

  如果我们在List列表页也调用navigate('List'),我们发现不会产生任何的效果,因为我们已经在列表页面了。navigate的含义是跳到这个页面,有点类似vue-router的router.replace

  如果我们确实想要打开多个页面,可以将navigate改成push:

1
2
3
4
<Button 
onPress={() => this.props.navigation.push('List')}
title="Go List Again">
</Button>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

push跳转

  每次我们调用push,都会在历史记录中新增一条记录,这也就是堆栈导航的由来;而调用navigate,它会尝试在现在的路由堆栈中查找是否有这个路由,没有的话才会新增。

  在堆栈导航的顶部有一个返回按钮,点击后可以返回上一个页面,我们也调用navigation.goBack()来触发返回:

1
2
3
4
<Button 
onPress={() => this.props.navigation.goBack()}
title="Go Back">
</Button>

  有时候堆栈导航的层级很深,我们需要穿越好几个页面才能返回到第一个页面;在这种情况下如果我们明确的知道我们想要回到的是Home页,我们可以直接调用navigation.navigate('Home'),清除所有的路由并且回到Home。

  另一种方式是调用navigation.popToTop(),这样将清除路由堆栈并回到堆栈的第一个页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ListScreen({navigation}) {
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<Text>List Screen</Text>
<Button
onPress={() => navigation.push('List')}
title="Push List"></Button>
<Button
onPress={() => navigation.navigate('Home')}
title="Go Home"></Button>
<Button onPress={() => navigation.popToTop()} title="Popup"></Button>
</View>
);
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

传递参数

  我们已经创建了多个页面并且在页面之间进行跳转,我们还需要传递不同的参数;例如从列表页到详情页需要传id,我们将需要传递的数据作为navigate函数的第二个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function ListScreen({navigation}) {
return (
<View>
<Button
title="Go to Details"
onPress={() => {
navigation.navigate('Details', {
id: 86,
otherParam: 'anything you want here',
});
}}
/>
</View>
);
}

  route对象是Screen组件增强的props,它里面包含一个属性params,就是用来接受传递过来的参数:

1
2
3
4
5
6
7
8
9
10
function DetailsScreen({route, navigation}) {
const {id, otherParam} = route.params;
return (
<View>
<Text>Details Screen</Text>
<Text>id: {id}</Text>
<Text>otherParam: {otherParam}</Text>
</View>
);
}

  页面也能更新自己的参数,就像更新state状态一样;通过navigation.setParams进行更新:

1
2
3
navigation.setParams({
otherParam: 'someText',
});

  在传递参数时,虽然我们可以将整个数据传过去,例如下面的方式:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
// 避免这样的写法
navigation.navigate('Detail', {
user: {
id: '18',
firstName: 'Jane',
lastName: 'Done',
age: 25,
},
});

  我们接收的时候也看似很方便的可以通过route.params.user就能获取到数据;但是这是一种反模式,例如用户数据等,应该通过接口获取,或者放在全局的用户列表中,然后通过id进行获取。

1
navigation.navigate('Profile', { userId: '18' });

导航栏配置

  我们在上面介绍了配置导航栏的标题,我们继续看下options还有哪些用法;它除了接收一个对象,还可以接收一个返回对象的函数;函数的方式可以接收navigation和route两个参数,这种方式会很有用,例如我们在设置导航栏的组件时,获取这两个参数进行跳转操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Stack.Navigator>
<Stack.Screen
name="Home"
component={HomeScreen}
options={({navigation, route}) => {
return {
headerTitle: () => (
<View>
<Text>标题:{route.params.title}</Text>
</View>
),
headerRight: () => (
<Button
title="Go List"
onPress={() => navigation.navigate('List')}></Button>
),
};
}}></Stack.Screen>
</Stack.Navigator>

  headerTitle可以覆写标题组件,替换成我们自定义的;headerRight定义导航栏右侧的组件,可以是一些功能性的,比如设置、帮助等等,可能会涉及路由的跳转,因此我们可以将options设置成函数的形式。

  我们可以调整导航栏标题的样式,通过下面三个参数:

  我们肯定希望我们的App大部分的导航栏都是相同样式,有个别样式需要定制;我们将Options移动到Stack.Navigator的screenOptions上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: '#f4511e',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}>
<Stack.Screen
name="Home"
component={HomeScreen}
></Stack.Screen>
</Stack.Navigator>

screenOptions

  这样,所有的导航栏的配置都是相同的了;有时候我们还需要和导航栏互动,修改导航栏的配置,我们可以调用navigation.setOptions来重新设置options。

1
2
3
4
5
6
7
8
9
function HomeScreen({ navigation }){
return (
<View>
<Button title="Update" onPress={()=> navigation.setOptions({
title: 'New Home',
headerRight: () => <Button title="Setting"></Button>,
})}></Button>
</View>)
}

setOptions

导航的生命周期

  在上一小节中,我们在不同的页面中进行导航跳转,当我们从a页面去b页面时,我们怎么才能知道即将要离开a页面?从b页面返回时,如果我们需要更新a页面中的数据,那我们在a页面如何监听呢?

  很多同学会理所当然的认为离开a页面时,我们直接在componentWillUnmount处理可以了;但是实际上,a页面只是暂时的隐藏到后台了,它并没有被销毁,始终保持了挂载状态,因此它的componentWillUnmount并不会被调用;而b页面则是会进入时创建,返回时被销毁。

  选项卡导航在操作时也会观察到类似的情况,由于它有多个tab页,我们可以将它想象成多个堆栈导航,它的每个tab切换时也只是将页面隐藏,并不会销毁。那我们怎么回到刚开始的问题,如何发现用户在进入它和离开它呢?

  我们通过navigation导航来订阅相关的事件,通过监听对应的事件来了解页面何时进入以及离开。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
export default class Screen extends Component {
constructor(props) {
super(props);
}
componentDidMount(){
this.props.navigation.addListener('focus', () => {
console.log('页面进入');
});
this.props.navigation.addListener('blur', () => {
console.log('页面离开');
});
}
}

  navigation支持以下五种事件:

  navigation.addListener返回一个函数,可以在组件销毁时调用来取消订阅的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default class Screen extends Component {
constructor(props) {
super(props);
}
componentDidMount(){
this._focus = this.props.navigation.addListener('focus', () => {
// ....
});
}
componentWillUnmount(){
this._focus()
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

选项卡导航

  我们再来看下另一种常见的导航方式:选项卡导航;我们在常用的App中都能看到这种导航方式,如微信、知乎、某东、某猫底部导航,在屏幕底部显示三到五个App的主要板块,能够很方便的让用户在目标板块之间进行切换操作,避免路由层次过深。

选项卡导航设计原则

  React Natigation中主要使用的选项卡导航就是@react-navigation/bottom-tabs,其他的还有下面这种material风格的:

material风格选项卡导航

  material风格主要是@react-navigation/material-bottom-tabs@react-navigation/material-top-tabs,使用方法都大同小异,只是风格不同,需要和App整体的风格协调。我们回到bottom-tabs,首先需要进行安装:

1
2
3
npm install @react-navigation/bottom-tabs
# 或者
yarn add @react-navigation/bottom-tabs

  我们看下bottom-tabs的简单使用案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Tab = createBottomTabNavigator();
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';

class TabRouter extends Component {
render() {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="List" component={ListScreen} />
</Tab.Navigator>
)
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  这样我们看到底部多了两个tab按钮,但是没有icon,比较简陋;这里引入react-native-vector-icons这个库,包含很多icon图标。遵循安装教程安装好后,我们在它的主页上,找到我们需要的icon,这里包含了AntDesign、FontAwesome、Ionicons、MaterialIcons等一众丰富的图标。

  我们可以给每个tab设置单独的options,但是为了方便统一,我们在Tab.Navigator上的screenOptions集中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Ionicons from 'react-native-vector-icons/Ionicons';

<Tab.Navigator
screenOptions={({route}) => ({
tabBarActiveTintColor: 'tomato',
tabBarInactiveTintColor: 'gray',
tabBarIcon: ({focused, color, size}) => {
let iconName;
if (route.name === 'Home') {
iconName = focused ? 'home': 'home-outline';
}
if (route.name === 'List') {
iconName = focused ? 'list-circle': 'list-circle-outline';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
})}>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="List" component={ListScreen} />
</Tab.Navigator>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Tab导航

  tabBarActiveTintColor和tabBarInactiveTintColor从名字我们就能看出,是设置激活状态和非激活的icon和label的颜色(注意,是两者的颜色);tabBarIcon用来设置icon图标,接收一个函数,函数传入三个参数:focused(boolean)、color(string)和size(number)。

  focused用来表示tab激活或者非激活,很好理解,但是这里的color和size就很奇怪了,我们打印color的值,发现和上面的active color和inactive color相同;这个色值是为了和icon下面的label色值保持统一而传入的,我们可以用它的色值,也可以和label不同;size则是导航预期icon的大小。

  如果我们只需要展示图标,而不要label,将tabBarShowLabel选项置为false即可。

1
2
3
4
5
6
<Tab.Navigator
screenOptions={({route}) => ({
tabBarShowLabel: false,
})}>
....
</Tab.Navigator>

徽章

  有时候,我们想要在tab按钮上加一个徽章(Badge),来标识未读信息,可以使用tabBarBadge选项:

1
<Tab.Screen name="Home" component={HomeScreen} options={{ tabBarBadge: 3 }} />

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Badge

路由跳转和嵌套

  上面堆栈导航我们介绍过navigate和push的用法,而选项卡导航就比较简单了,由于两个tab是同一级关系,直接调用navigate就能实现路由跳转:

1
2
3
4
5
6
7
8
9
10
11
function HomeScreen({ navigation }) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home!</Text>
<Button
title="去列表页"
onPress={() => navigation.navigate('List')}
/>
</View>
);
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

选项卡导航中不能调用push函数。

  想象一下,在选项卡导航中我们经常用到不止一个路由,在列表页后面我们需要跳转到详情页;但如果直接把详情页面放到Tab.Screen中肯定是不行的,这样只会增加一个tab按钮。我们可以利用上面的堆栈导航,将两种导航方式进行嵌套使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const Tab = createBottomTabNavigator();

const ListStack = createNativeStackNavigator();

function ListStackScreen() {
return (
<ListStack.Navigator>
<ListStack.Screen
name="List"
component={ListScreen}>
</ListStack.Screen>
<ListStack.Screen
name="Detail"
component={DetailScreen}>
</ListStack.Screen>
</ListStack.Navigator>
);
}

function TabStackRouter({ navigation }) {
return (
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen} />
<Tab.Screen
name="ListStack"
component={ListStackScreen} />
</Tab.Navigator>
)
}

  我们创建了一个ListStack堆栈导航,相当于在tab页面中嵌套了一层堆栈导航;我们很明显发现List页面会有两个头部的导航栏,我们将ListStack的headerShown置为false即可将它的导航栏隐藏:

1
2
3
4
5
6
7
8
9
10
function TabStackRouter({ navigation }) {
<Tab.Navigator>
<Tab.Screen
name="ListStack"
component={ListStackScreen}
options={{
headerShown: false,
}} />
</Tab.Navigator>
}

路由嵌套

安全区域

  在iPhone X、iPhone XR等设备上,顶部的刘海设计和底部的小黑条都可能会遮住我们的App内容,因此需要进行适配;尽管RN提供了SafeAreaView,但它有一些问题,React Navigation提供了更好用的react-native-safe-area-context

遮住

  首先我们yarn安装,在iOS平台多一步pod安装:

1
2
3
4
yarn add react-native-safe-area-context

# iOS Platform
npx pod-install

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  首先在根组件使用SafeAreaProvider,这是一个提供者,本身不会对布局产生影响,只有在该组件包裹下的子组件才能使用react-native-safe-area-context提供的功能,因此我们通常把它包裹在App组件:

1
2
3
4
5
6
7
import { SafeAreaProvider } from 'react-native-safe-area-context';

function App() {
return (<SafeAreaProvider initialMetrics={null}>
<NavigationContainer>{/*(...) */}</NavigationContainer>
</SafeAreaProvider>);
}

  在我们要适配的页面引入SafeAreaView自动处理:

1
2
3
4
5
6
7
8
9
10
11
12
import { SafeAreaView } from 'react-native-safe-area-context';

function HomeScreen() {
return (
<SafeAreaView
style={{ flex: 1, justifyContent: 'space-between', alignItems: 'center' }}
>
<Text>This is Top Text.</Text>
<Text>This is Bottom Text.</Text>
</SafeAreaView>
);
}

SafeAreaView

React Navigation内置了react-native-safe-area-context,一般如果使用了导航栏和底部的tab则无需处理。

导航栏和底部自动处理了

抽屉式导航

  抽屉式导航是从侧边栏划出抽屉一样的效果,抽屉式导航的核心是「隐藏」,突出核心功能,隐藏一些不必要的操作,比如一些简报、新闻、栏目等等;在小屏幕时代,内容篇幅展示有限,会把一些辅助的功能放到侧边栏里;但是随着屏幕尺寸越来越大,通过新的交互方式,我们的App已经能够容纳足够多的内容,抽屉式导航的缺点也逐渐暴露:交互效率低下,处于操作盲区,单手操作不便。

  因此抽屉式导航也逐渐衰落了,我们在大多App中也很难看到这种导航方式,仅有少数还保留,我们看下在虎嗅App上使用抽屉式导航的效果:

虎嗅App的抽屉式导航

  要在我们的App中使用这种导航,我们首先安装几个依赖:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
yarn add @react-navigation/drawer
yarn add react-native-gesture-handler react-native-reanimated

  和其他两种导航方式相同,我们需要把创建函数从依赖中导出:

1
2
3
4
5
6
7
8
9
10
11
12
import { createDrawerNavigator } from '@react-navigation/drawer';

const Drawer = createDrawerNavigator();

function DrawerRouter() {
return (
<Drawer.Navigator>
<Drawer.Screen name="Home" component={HomeScreen} />
<Drawer.Screen name="List" component={ListScreen} />
</Drawer.Navigator>
);
}

  我们可以点击左上角的按钮展开抽屉,或者在页面调用如下API进行手动打开或关闭:

1
2
3
navigation.openDrawer();
navigation.closeDrawer();
navigation.toggleDrawer();

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  还可以定义drawerPosition,设置打开抽屉的位置,支持left(默认)和right:

1
2
3
4
5
6
<Drawer.Navigator
screenOptions={{
drawerPosition: 'right',
}}>
...
</Drawer.Navigator>

  我们可以设置打开抽屉的内容,通过drawContentView很容易的覆写内容。默认情况下在ScrollView下渲染了一个DrawerItemList元素,在这基础上我们可以进行自定义,也可以使用DrawerItem元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {
DrawerContentScrollView,
DrawerItemList,
DrawerItem,
} from '@react-navigation/drawer';

function drawContentView(props) {
return (
<DrawerContentScrollView {...props}>
<DrawerItemList {...props} />
{/* 这里加入想要新增的一些元素 */}
<DrawerItem label="Help" onPress={...} />
</DrawerContentScrollView>
);
}

function DrawerRouter() {
return (
<Drawer.Navigator
drawerContent={props => drawContentView(props)}>
...
</Drawer.Navigator>
);
}

抽屉式导航效果

彩蛋

  经过前面几篇对RN的学习,我们基本已经掌握了RN的相关开发技巧,下面我们就开始进行实战环节,从零开始开发一款属于我们自己的App,敬请期待。


谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。