react-navigation 5.x 最佳實踐

俊寧發表於2020-02-22

文章示例原始碼: github.com/youngjuning…

安裝依賴

$ yarn add @react-navigation/native @react-navigation/stack @react-navigation/bottom-tabs react-native-reanimated react-native-gesture-handler react-native-screens react-native-safe-area-context @react-native-community/masked-view
複製程式碼

配置

為了完成 react-native-screens 的安裝,新增下面兩行程式碼到 android/app/build.gradle 檔案的 dependencies 部分中:

implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha02'
複製程式碼

為了完成 react-native-gesture-handler 的安裝, 在入口檔案的頂部新增下面的程式碼, 比如 index.jsApp.js:

import 'react-native-gesture-handler';
複製程式碼

現在,我們需要把整個 App用 NavigationContainer包裹:

import React from 'react';
import { NavigationContainer } from '@react-navigation/native';

const App = () => {
  return (
    <NavigationContainer>
      {/* Rest of your app code */}
    </NavigationContainer>
  );
};

export default App;
複製程式碼

App.js

import React from 'react';
import {
  View,
  Text,
  StyleSheet,
  SafeAreaView,
  StatusBar,
  BackHandler,
} from 'react-native';
import {NavigationContainer, useFocusEffect} from '@react-navigation/native';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {createStackNavigator, HeaderBackButton} from '@react-navigation/stack';
import {IconOutline} from '@ant-design/icons-react-native';
import {Button} from '@ant-design/react-native';
import IconWithBadge from './IconWithBadge';
import HeaderButtons from './HeaderButtons';
import getActiveRouteName from './getActiveRouteName';
import getScreenOptions from './getScreenOptions';
import {navigationRef} from './NavigationService';

const HomeScreen = ({navigation, route}) => {
  navigation.setOptions({
    headerLeft: props => (
      <HeaderBackButton
        {...props}
        onPress={() => {
          console.log('不能再返回了!');
        }}
      />
    ),
    headerRight: () => (
      <HeaderButtons>
        {/* title、iconName、onPress、IconComponent、iconSize、color */}
        <HeaderButtons.Item
          title="新增"
          iconName="plus"
          onPress={() => console.log('點選了新增按鈕')}
          iconSize={24}
          color="#ffffff"
        />
      </HeaderButtons>
    ),
  });

  useFocusEffect(
    React.useCallback(() => {
      // Do something when the screen is focused
      return () => {
        // Do something when the screen is unfocused
        // Useful for cleanup functions
      };
    }, []),
  );
  const {author} = route.params || {};
  return (
    <>
      <StatusBar barStyle="dark-content" />
      <View style={styles.container}>
        <Text>Home Screen</Text>
        <Text>{author}</Text>
        <Button
          type="warning"
          // 使用 setOptions 更新標題
          onPress={() => navigation.setOptions({headerTitle: 'Updated!'})}>
          Update the title
        </Button>
        <Button
          type="primary"
          onPress={() =>
            // 跳轉到指定頁面,並傳遞兩個引數
            navigation.navigate('DetailsScreen', {
              otherParam: 'anything you want here',
            })
          }>
          Go to DetailsScreen
        </Button>
        <Button
          type="warning"
          onPress={() => navigation.navigate('SafeAreaViewScreen')}>
          Go SafeAreaViewScreen
        </Button>
        <Button
          type="primary"
          onPress={() =>
            navigation.navigate('CustomAndroidBackButtonBehaviorScreen')
          }>
          Go CustomAndroidBackButtonBehavior
        </Button>
      </View>
    </>
  );
};

const DetailsScreen = ({navigation, route}) => {
  // 通過 props.route.params 接收引數
  const {itemId, otherParam} = route.params;
  return (
    <View style={styles.container}>
      <Text>Details Screen</Text>
      <Text>itemId: {itemId}</Text>
      <Text>otherParam: {otherParam}</Text>
      <Button
        type="primary"
        // 返回上一頁
        onPress={() => navigation.goBack()}>
        Go back
      </Button>
      <Button
        type="primary"
        // 如果返回上一個頁面需要傳遞引數,請使用 navigate 方法
        onPress={() => navigation.navigate('HomeScreen', {author: '楊俊寧'})}>
        Go back with Params
      </Button>
    </View>
  );
};

const SettingsScreen = ({navigation, route}) => {
  return (
    <SafeAreaView
      style={{flex: 1, justifyContent: 'space-between', alignItems: 'center'}}>
      <Text>This is top text.</Text>
      <Text>This is bottom text.</Text>
    </SafeAreaView>
  );
};

const SafeAreaViewScreen = () => {
  return (
    <SafeAreaView
      style={{flex: 1, justifyContent: 'space-between', alignItems: 'center'}}>
      <Text>This is top text.</Text>
      <Text>This is bottom text.</Text>
    </SafeAreaView>
  );
};

const CustomAndroidBackButtonBehaviorScreen = ({navigation, route}) => {
  useFocusEffect(
    React.useCallback(() => {
      const onBackPress = () => {
        alert('物理返回鍵被攔截了!');
        return true;
      };

      BackHandler.addEventListener('hardwareBackPress', onBackPress);

      return () =>
        BackHandler.removeEventListener('hardwareBackPress', onBackPress);
    }, []),
  );
  return (
    <View style={styles.container}>
      <Text>AndroidBackHandlerScreen</Text>
    </View>
  );
};

const Stack = createStackNavigator();
const BottomTab = createBottomTabNavigator();
const BottomTabScreen = () => (
  <BottomTab.Navigator
    screenOptions={({route}) => ({
      tabBarIcon: ({focused, color, size}) => {
        let iconName;
        if (route.name === 'HomeScreen') {
          iconName = focused ? 'apple' : 'apple';
          return (
            <IconWithBadge badgeCount={90}>
              <IconOutline name={iconName} size={size} color={color} />
            </IconWithBadge>
          );
        } else if (route.name === 'SettingsScreen') {
          iconName = focused ? 'twitter' : 'twitter';
        }
        return <IconOutline name={iconName} size={size} color={color} />;
      },
    })}
    tabBarOptions={{
      activeTintColor: 'tomato',
      inactiveTintColor: 'gray',
    }}>
    <Stack.Screen
      name="HomeScreen"
      component={HomeScreen}
      options={{tabBarLabel: '首頁'}}
    />
    <Stack.Screen
      name="SettingsScreen"
      component={SettingsScreen}
      options={{tabBarLabel: '設定'}}
    />
  </BottomTab.Navigator>
);
const App = () => {
  const routeNameRef = React.useRef();
  return (
    <>
      <NavigationContainer
        ref={navigationRef}
        onStateChange={state => {
          const previousRouteName = routeNameRef.current;
          const currentRouteName = getActiveRouteName(state);
          if (previousRouteName !== currentRouteName) {
            console.log('[onStateChange]', currentRouteName);
            if (currentRouteName === 'HomeScreen') {
              StatusBar.setBarStyle('dark-content'); // 修改 StatusBar
            } else {
              StatusBar.setBarStyle('dark-content'); // 修改 StatusBar
            }
          }
          // Save the current route name for later comparision
          routeNameRef.current = currentRouteName;
        }}>
        <Stack.Navigator
          initialRouteName="HomeScreen"
          // 頁面共享的配置
          screenOptions={getScreenOptions()}>
          <Stack.Screen
            name="BottomTabScreen"
            component={BottomTabScreen}
            options={{headerShown: false}}
          />
          <Stack.Screen
            name="DetailsScreen"
            component={DetailsScreen}
            options={{headerTitle: '詳情'}} // headerTitle 用來設定標題欄
            initialParams={{itemId: 42}} // 預設引數
          />
          <Stack.Screen
            name="SafeAreaViewScreen"
            component={SafeAreaViewScreen}
            options={{headerTitle: 'SafeAreaView'}}
          />
          <Stack.Screen
            name="CustomAndroidBackButtonBehaviorScreen"
            component={CustomAndroidBackButtonBehaviorScreen}
            options={{headerTitle: '攔截安卓物理返回鍵'}}
          />
        </Stack.Navigator>
      </NavigationContainer>
    </>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default App;
複製程式碼

路由名稱的大小寫無關緊要 -- 你可以使用小寫字母home或大寫字母Home,這取決於你的喜好。 我們更喜歡將路由名稱大寫。 我們更喜歡利用我們的路由名稱。

跳轉方法有 navigatepushgoBackpopToTop

可以用 navigation.setParams 方法更新頁面的引數

我們可以通過 options={({ route, navigation }) => ({ headerTitle: route.params.name })} 的方式在標題中使用引數

我們可以用 navigation.setOptions 更新頁面配置

  • Stack.Navigator
    • initialRouteName : 用來配置 Stack.Navigator 的初始路由
    • screenOptions: 頁面共享配置物件
  • Stack.Screen
    • name: 頁面名
    • component: 頁面對應元件
    • options: 頁面配置物件
    • initialParams: 預設引數

HeaderButtons.js

使用 react-navigation-header-buttons 元件搭配任意 Icon 元件可以自定義自己的 Header Button 元件,我這裡為了演示方便,使用了 @ant-design/icons-react-native

import React from 'react';
import {
  HeaderButtons as RNHeaderButtons,
  HeaderButton as RNHeaderButton,
  Item,
} from 'react-navigation-header-buttons';
import {IconOutline} from '@ant-design/icons-react-native';

const HeaderButton = props => {
  return (
    <RNHeaderButton
      {...props}
      IconComponent={IconOutline}
      iconSize={props.iconSize || 23}
      color={props.color || '#000000'}
    />
  );
};

const HeaderButtons = props => {
  return <RNHeaderButtons HeaderButtonComponent={HeaderButton} {...props} />;
};

HeaderButtons.Item = Item;

export default HeaderButtons;
複製程式碼

IconWithBadge.js

import React from 'react';
import {View} from 'react-native';
import {Badge} from '@ant-design/react-native';

const IconWithBadge = ({children, badgeCount, ...props}) => {
  return (
    <View style={{width: 24, height: 24, margin: 5}}>
      {children}
      <Badge
        {...props}
        style={{position: 'absolute', right: -6, top: -3}}
        text={badgeCount}
      />
    </View>
  );
};

export default IconWithBadge;
複製程式碼

getActiveRouteName.js

/**
 * Gets the current screen from navigation state
 * @param state
 */
const getActiveRouteName = state => {
  const route = state.routes[state.index];

  if (route.state) {
    // Dive into nested navigators
    return getActiveRouteName(route.state);
  }

  return route.name;
};

export default getActiveRouteName;

複製程式碼

getScreenOptions.js

import {TransitionPresets} from '@react-navigation/stack';

const getScreenOptions = () => {
  return {
    headerStyle: {
      backgroundColor: '#ffffff',
    }, // 一個應用於 header 的最外層 View 的 樣式物件
    headerTintColor: '#000000', // 返回按鈕和標題都使用這個屬性作為它們的顏色
    headerTitleStyle: {
      fontWeight: 'bold',
    },
    headerBackTitleVisible: false,
    headerTitleAlign: 'center',
    cardStyle: {
      flex: 1,
      backgroundColor: '#f5f5f9',
    },
    ...TransitionPresets.SlideFromRightIOS,
  };
};

export default getScreenOptions;

複製程式碼

NavigationService.js

import React from 'react';

export const navigationRef = React.createRef();

const navigate = (name, params) => {
  navigationRef.current && navigationRef.current.navigate(name, params);
};

const getNavigation = () => {
  return navigationRef.current && navigationRef.current;
};

export default {
  navigate,
  getNavigation,
};
複製程式碼

頁面生命週期與React Navigation

一個包含 頁面 A 和 B 的 StackNavigator ,當跳轉到 A 時,componentDidMount 方法會被呼叫; 當跳轉到 B 時,componentDidMount 方法也會被呼叫,但是 A 依然在堆疊中保持 被載入狀態,他的 componentWillUnMount 也不會被呼叫。

當從 B 跳轉到 A,B的 componentWillUnmount 方法會被呼叫,但是 A 的 componentDidMount方法不會被呼叫,應為此時 A 依然是被載入狀態。

React Navigation 生命週期事件

addListener

function Profile({ navigation }) {
  React.useEffect(() => {
    const unsubscribe = navigation.addListener('focus', () => {
      // Screen was focused
      // Do something
    });

    return unsubscribe;
  }, [navigation]);

  return <ProfileContent />;
}
複製程式碼

useFocusEffect

useFocusEffect(
    React.useCallback(() => {
      // Do something when the screen is focused
      return () => {
        // Do something when the screen is unfocused
        // Useful for cleanup functions
      };
    }, []),
  );
複製程式碼

隱藏 Header/TabBar

  • headerMode:"none": hide Header for Stack.Navigator
  • headerShown:false: hide Header for Stack.Screen
  • tabBar={() => null}: hide TabBar for BottomTab.Navigator
import {NavigationContainer, useFocusEffect} from '@react-navigation/native';
import {createStackNavigator, TransitionPresets, HeaderBackButton} from '@react-navigation/stack';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';

const Stack = createStackNavigator();
const BottomTab = createBottomTabNavigator();

export default App = () => {
  <NavigationContainer>
  	<Stack.Navigator headerMode="none">
      <Stack.Screen
        ...
        options={{ headerShown: false }}
      />
      <Stack.Screen ...>
        {() => (
          <BottomTab.Navigator
            ...
           	tabBar={() => null}
          >
            ...
          </BottomTab.Navigator>
        )}
      </Stack.Screen>
    </Stack.Navigator>
  </NavigationContainer>
}
複製程式碼

TabBar 的 StatusBar 不同

一般我們會對特殊的那個TabBar進行處理。

const getActiveRouteName = state => {
  const route = state.routes[state.index];

  if (route.state) {
    // Dive into nested navigators
    return getActiveRouteName(route.state);
  }

  return route.name;
};

const App = () => {
  const ref = React.useRef(null);
	return (
    <>
    	{/* 訪問 ref.current?.navigate */}
      <NavigationContainer
        ref={ref}
        onStateChange={state => {
          const previousRouteName = ref.current;
          const currentRouteName = getActiveRouteName(state);
          if (previousRouteName !== currentRouteName) {
            console.log('[onStateChange]', currentRouteName);
            if (currentRouteName === 'HomeScreen') {
              StatusBar.setBarStyle('dark-content');  // 修改 StatusBar
            } else {
              StatusBar.setBarStyle('dark-content');  // 修改 StatusBar
            }
          }
        }}
      >
      </NavigationContainer>
    </>
	)
}
複製程式碼

監聽安卓物理返回鍵

import {View, Text, BackHandler} from 'react-native';
const CustomAndroidBackButtonBehaviorScreen = ({navigation, route}) => {
  useFocusEffect(
    React.useCallback(() => {
      const onBackPress = () => {
        alert('物理返回鍵被攔截了!');
        return true;
      };
      BackHandler.addEventListener('hardwareBackPress', onBackPress);
      return () =>
        BackHandler.removeEventListener('hardwareBackPress', onBackPress);
    }, []),
  );
  return (
    <View style={styles.container}>
      <Text>AndroidBackHandlerScreen</Text>
    </View>
  );
};
複製程式碼

在子元件中訪問 navigation

我們可以通過 useNavigation() hook 來訪問 navigation,再也不用傳遞多層 navigation

import React from 'react';
import { Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';

function GoToButton({ screenName }) {
  const navigation = useNavigation();

  return (
    <Button
      title={`Go to ${screenName}`}
      onPress={() => navigation.navigate(screenName)}
    />
  );
}
複製程式碼

給頁面傳遞額外的屬性

<Stack.Screen
  name="HomeScreen"
  options={{headerTitle: '首頁'}}>
  {props => <HomeScreen {...props} extraData={{author: '楊俊寧'}} />}
</Stack.Screen>
複製程式碼

獲取 Header Height

import { useHeaderHeight } from '@react-navigation/stack'

const App = () => {
    const HeaderHeight = useHeaderHeight() // 獲取Header Height
    return(...)
}

export default App
複製程式碼

繼續使用類元件

考慮到不適應 Hooks 的但是業務又很緊急的場景,我們可以再類元件之上封裝一層來支援 React Navigation 的 Hooks 元件,之所以這麼做,起因是因為 React Navigation 5 中我們只能通過 useHeaderHeight() 方法獲取標題欄高度。

class Albums extends React.Component {
  render() {
    return <ScrollView ref={this.props.scrollRef}>{/* content */}</ScrollView>;
  }
}
// 封裝並匯出
export default function(props) {
  const ref = React.useRef(null);
  useScrollToTop(ref);
  return <Albums {...props} scrollRef={ref} />;
}
複製程式碼

聯絡作者

作者微信 知識星球 讚賞作者
react-navigation  5.x 最佳實踐 react-navigation  5.x 最佳實踐 react-navigation  5.x 最佳實踐