React Native v2 - Navigation

前言

最近要轉換跑道學習開發 React Native,學習的課程是 Kadi Kraman 的 React Native v2 ,寫這篇筆記的時候是學習 React Native 的第二天,App 的 navigate 和 web 差蠻多的,目前處於一個對未知領域感到有趣的心裡狀態。

目前除了 React Native 以外,還有 TypeScript 這個挑戰,因為我對於 TypeScript 沒有到很熟,常常搞 type 搞到有點心死。

初學 React Native,概念上應該會有不少錯誤,如果有誤的話也麻煩不吝指正,非常感謝!

Navigation Intro

React Native 沒有內建 Navigation,因為 Facebook 有他們自己內部使用的 Navigation 方案,因為高度客製化的關係也沒辦法釋出。

目前 React Navigation 社群有兩個比較夯的 library: React Navigation and React Native Navigation,這個課程會使用 React Navigation。

Expo 選用 React Navigation 是更為合適的,因為 React Native Navigation 背後是 Native 的 code,如果要使用這個 library 的話必須要 eject 才行,這點就不是那麼地契合,另外,效能上的話兩者沒有差異。

[Expo] Adding navigation with Expo

首先安裝 React Navigation:

yarn add @react-navigation/native

接下來安裝 Native 的 dependencies:

expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view

接著在 App.tsx import :

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

// ... 中間省略

const App = () => {
  const renderColorBox = ({ item }: { item: Colors }) => {
    return <ColorBox colorName={item.colorName} hexColor={item.hexCode} />;
  };

  return (
    <NavigationContainer>
      <SafeAreaView style={styles.safeArea}>
        <FlatList
          style={styles.container}
          data={COLORS}
          renderItem={renderColorBox}
          keyExtractor={(item) => item.hexCode}
          ListHeaderComponent={<Text style={styles.title}>Solarized</Text>}
        />
      </SafeAreaView>
    </NavigationContainer>
  );
};

這樣子基本的建設就完成了。

Adding Navigation

一般來說,App 的 Navigation 會分成兩種,Bottom Navigation 以及 stack 的 navigation,舉例來說就類似於我在網路上隨便找的 例子,會發現到他既有 bottom 的導航又能夠在螢幕中導向其他頁面。

目前課程的例子只會用到 stack navigator,這次的練習是要建立兩個 Screen,Home 以及 ColorPalette

Home 就是首頁,先不用寫什麼東西,只要有 Solarized 的 Text,然後點擊可以導航到 ColorPalette 就好了;ColorPalette 就是我們原本在 App.tsx 裡面的調色盤,所以只要原封不動把 code 搬過去。

既然知道目標了,那就開始吧!首先我們要安裝 dependencies:

yarn add @react-navigation/stack

接著在 root 新增 screens 的資料夾,分別在裡面建立 Home.tsxColorPalette.tsx

Home.tsx

import React from 'react';
import { View, Text } from 'react-native';

export default function Home() {
  return (
    <View>
      <Text>Hello World, This is Home Screen</Text>
    </View>
  );
}

ColorPalette.tsx

import React from 'react';
import { Text, SafeAreaView, StyleSheet, FlatList } from 'react-native';

import ColorBox from '../components/ColorBox';

type Colors = {
  colorName: string;
  hexCode: string;
};

const COLORS = [
  { colorName: 'Base03', hexCode: '#002b36' },
  { colorName: 'Base02', hexCode: '#073642' },
  { colorName: 'Base01', hexCode: '#586e75' },
  { colorName: 'Base00', hexCode: '#657b83' },
  { colorName: 'Base0', hexCode: '#839496' },
  { colorName: 'Base1', hexCode: '#93a1a1' },
  { colorName: 'Base2', hexCode: '#eee8d5' },
  { colorName: 'Base3', hexCode: '#fdf6e3' },
  { colorName: 'Yellow', hexCode: '#b58900' },
  { colorName: 'Orange', hexCode: '#cb4b16' },
  { colorName: 'Red', hexCode: '#dc322f' },
  { colorName: 'Magenta', hexCode: '#d33682' },
  { colorName: 'Violet', hexCode: '#6c71c4' },
  { colorName: 'Blue', hexCode: '#268bd2' },
  { colorName: 'Cyan', hexCode: '#2aa198' },
  { colorName: 'Green', hexCode: '#859900' },
];

export default function ColorPalette() {
  const renderColorBox = ({ item }: { item: Colors }) => {
    return <ColorBox colorName={item.colorName} hexColor={item.hexCode} />;
  };

  return (
    <SafeAreaView style={styles.safeArea}>
      <FlatList
        style={styles.container}
        data={COLORS}
        renderItem={renderColorBox}
        keyExtractor={(item) => item.hexCode}
        ListHeaderComponent={<Text style={styles.title}>Solarized</Text>}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  title: {
    fontSize: 18,
    marginBottom: 10,
    fontWeight: 'bold',
  },
  container: {
    paddingTop: 20,
    paddingHorizontal: 20,
    borderRadius: 10,
    flex: 1,
  },
  safeArea: {
    flex: 1,
    flexDirection: 'row',
  },
});

這時候我們的 App.tsx 經過 code 的搬遷後,會長得像這樣:

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

const App = () => {
  return (
    <NavigationContainer>
    </NavigationContainer>
  );
};

export default App;

處理好之後,就來做正事了,開始建立 Navigation,詳細的內容可以參考 React Navigation 官方文件的 Hello React Navigation,首先建立 Stack:

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

const Stack = createStackNavigator<RootStackParamList>();

const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator>
      </Stack.Navigator>
    </NavigationContainer>
  );
};

export default App;

Stack.Navigator 就是要包裹著各類 Screen 的 Container,就如同 React-Router 也有 <Router> Component 來當作包裹容器差不多。

接著,我們引入 HomeColorPalette 兩個 Component,並且加上 Stack.Screen

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

import Home from './screens/Home';
import ColorPalette from './screens/ColorPalette';

const Stack = createStackNavigator();

const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="ColorPalette" component={ColorPalette} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};

export default App;

Stack.Screenname props 代表了它的路徑名稱,通常會用大寫;component 則是你想要顯示的頁面。

接著,我們回到 Home.tsx

import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { StackNavigationProp } from '@react-navigation/stack';

export default function Home({ navigation }) {
  return (
    <Pressable onPress={() => navigation.navigate('ColorPalette')}>
      <View>
        <Text>Hello World, This is Home Screen</Text>
      </View>
    </Pressable>
  );
}

課程裡面是使用到 TouchableOpacity,不過我看到官方文件提到這段話:

If you’re looking for a more extensive and future-proof way to handle touch-based input, check out the Pressable API.

所以就改成使用 <Pressable 的 Component 了。

基本上這樣就完成了簡單的 navigation!酷欸!另外 Kadi 也有提到,ColorPalette<SafeArea> 也可以不用了,因為 React Navigation 有幫你做好這層處理。(Kadi 也沒說做了什麼,所以背後詳細做了什麼要等我更熟 React Native 才能解釋 QQ)

Type 呢?

對,因為我寫 TypeScript 的關係,所以和課程不同的地方是我多了 Type 要處理,如果寫 TypeScript 的人會發現剛剛那段 code 在 Home 的 props navigation 會出現紅字,因為沒有定義型別,所以接下來要處理的就是定義 navigation 的 type。

因此這時候就要參考 React Navigation 的 Type checking with TypeScript 了!

根據官方文件,首先我們要先寫好 RootStack 的 type:

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

import Home from './screens/Home';
import ColorPalette from './screens/ColorPalette';

export type RootStackParamList = {
  Home: undefined;
  ColorPalette: undefined;
};

const Stack = createStackNavigator<RootStackParamList>();

const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="ColorPalette" component={ColorPalette} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};

接著,回到我們的 Home.tsx screen:

import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { StackNavigationProp } from '@react-navigation/stack';

import { RootStackParamList } from '../App';

type HomeScreenNavigationProp = StackNavigationProp<RootStackParamList, 'Home'>;

type TProps = {
  navigation: HomeScreenNavigationProp;
};

export default function Home({ navigation }: TProps) {
  return (
    <Pressable onPress={() => navigation.navigate('ColorPalette')}>
      <View>
        <Text>Hello World, This is Home Screen</Text>
      </View>
    </Pressable>
  );
}

這樣就可以順利完成 type 的定義了,另外官方文件還有提到:

Similarly, you can import DrawerNavigationProp from @react-navigation/drawer, BottomTabNavigationProp from @react-navigation/bottom-tabs etc.

所以如果有其他的 Navigation 方式,定義 type 的方式也是差不多的,另外要用到 route props 的話,則是改成使用 RouteProp

但如果 navigationroute 都要使用到的話,官方是推薦改成使用 StackScreenProps,範例 code 如下:

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

type RootStackParamList = {
  Home: undefined;
  Profile: { userId: string };
  Feed: { sort: 'latest' | 'top' } | undefined;
};

type Props = StackScreenProps<RootStackParamList, 'Profile'>;

在 function component 使用就會像這樣:

function ProfileScreen({ route, navigation }: Props) {
  // ...
}

最後,官方是推薦把這些 type 另外放在一個 type.ts 裡,這樣就不用重複做一樣定義型別的事情惹。

感謝 React Navigation 的官方文件寫得如此詳細,讓我這個 TS 新手也能一步一腳印的處理好 type。

詳細的需求看這裡: Navigation Exercise

這章節的練習有四個需求:

  1. update the app so that the colors and name are being passed into the ColorPalette component, making it reusable. Docs
  2. make sure the page title will be the name of the color palette instead of the name of the page. Docs
  3. add two more color schemes: Rainbow and Frontend Masters (hint: you create a COLOR_PALETTES array and use a FlatList to render them)
  4. update the Home page to display the first 5 colors of the color scheme as preview (stretch goal)

除了原本的 SOLARIZED 的顏色,我們還多了兩種顏色:

const RAINBOW = [
  { colorName: 'Red', hexCode: '#FF0000' },
  { colorName: 'Orange', hexCode: '#FF7F00' },
  { colorName: 'Yellow', hexCode: '#FFFF00' },
  { colorName: 'Green', hexCode: '#00FF00' },
  { colorName: 'Violet', hexCode: '#8B00FF' },
];

const FRONTEND_MASTERS = [
  { colorName: 'Red', hexCode: '#c02d28' },
  { colorName: 'Black', hexCode: '#3e3e3e' },
  { colorName: 'Grey', hexCode: '#8a8a8a' },
  { colorName: 'White', hexCode: '#ffffff' },
  { colorName: 'Orange', hexCode: '#e66225' },
];

示意圖:

我們就來逐步完成它吧!

Part1 - 顏色的整理、新增 ColorPalette.tsx

第一個需求:

update the app so that the colors and name are being passed into the ColorPalette component, making it reusable. Docs

點擊 Docs 可以看到關於 navigation.navigate 的第二個參數可以傳遞 params,用法大概是這樣:navigation.navigate('RouteName', { /* params go here */ })

傳遞 params 之後,就可以在我們前往的 screen 得到相對應的 params,透過 props 當中的 route.params 可以拿到 navigate 傳遞的參數。

因為要處理的顏色變很多了,所以這邊先新增 utils 資料夾,並且在裡面創建 colors.ts,方便把顏色集中管理 :

export type TColor = {
  colorName: string;
  hexCode: string;
};

export const SOLARIZED: TColor[] = [
  /* 各種顏色 */
];

export const RAINBOW: TColor[] = [
  /* 各種顏色 */
];

export const FRONTEND_MASTERS: TColor[] = [
  /* 各種顏色 */
];

接著,再新增一個叫 COLOR_PALETTES 的常數,順便定義它的 type:

export type TColor = {
  colorName: string;
  hexCode: string;
};

export type TPalette = {
  paletteName: 'Solarized' | 'Rainbow' | 'Frontend Masters';
  colors: TColor[];
};

export const SOLARIZED: TColor[] = [
  /* 各種顏色 */
];

export const RAINBOW: TColor[] = [
  /* 各種顏色 */
];

export const FRONTEND_MASTERS: TColor[] = [
  /* 各種顏色 */
];

export const COLOR_PALETTES: TPalette[] = [
  { paletteName: 'Solarized', colors: SOLARIZED },
  { paletteName: 'Rainbow', colors: RAINBOW },
  { paletteName: 'Frontend Masters', colors: FRONTEND_MASTERS },
];

用途是待會我們要用 FlatList 來顯示這些 palette,所以先集中整理成一個 array。

接下來,我們就可以開始功能的實作了!首先,新增一個叫 PalettePreview.tsx 的檔案,檔如其名,這個檔案的功能是顯示「調色盤名稱(ColorPaletteName)」以及「五個基礎顏色」的顏色預覽,並且在點擊的時候可以到達相對應的調色盤。

我們要做的事情,就是把當初的 <Pressable><Text/> 先移動過來,不過特別注意的是,我這邊還是把 <Pressable> 改回了 <TouchableOpacity>,因為我發現這樣才有點擊時,元素變透明的回饋 XD

PalettePreview.tsx

import React from 'react';
import {
  View,
  Text,
  TouchableOpacity,
} from 'react-native';
import { TPalette } from '../utils/colors';

type TProps = {
  handlePress: () => void;
  colorPalette: TPalette;
};

function PalettePreview({ handlePress, colorPalette }: TProps) {
  return (
    <TouchableOpacity>
      <View>
        <Text>Hello World</Text>
      </View>
    </TouchableOpacity>
  );
}

這時候的 Home.tsx 的 JSX 是空空如也的。

Part2 - ColorPalette title

第二個需求:

Make sure the page title will be the name of the color palette instead of the name of the page.

根據 docs,可以看到如果要顯示動態的 title,必須要在 Stack.Screen 使用 options props,所以我們回到 App.tsx 做修改:

const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen
          name="ColorPalette"
          component={ColorPalette}
          options={({ route }) => ({ title: route.params.paletteName })} // 新增這行
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
};

因為到時候 params 會傳入 paletteName 及 colors 的關係,所以就可以透過 paletteName 來決定 title 是什麼了。

Part3 - 新增 Home Sceen Color Schemes

第三個需求:

Add a the new color schemes.

之後再回到 Home.tsx ,新增 FlatList ,並且定義好 renderColorPalette 的 function:

import React from 'react';
import { FlatList, StyleSheet } from 'react-native';
import { StackNavigationProp } from '@react-navigation/stack';

import { RootStackParamList } from '../App';
import PalettePreview from '../components/PalettePreview';
import { COLOR_PALETTES, TPalette } from '../utils/colors';

type HomeScreenNavigationProp = StackNavigationProp<RootStackParamList, 'Home'>;

type TProps = {
  navigation: HomeScreenNavigationProp;
};

export default function Home({ navigation }: TProps) {
  const renderPalettePreview = ({ item }: { item: TPalette }) => {
    return (
      <PalettePreview
        handlePress={() => navigation.navigate('ColorPalette', item)}
        colorPalette={item}
      />
    );
  };

  return (
    <FlatList
      style={styles.container}
      data={COLOR_PALETTES}
      renderItem={renderPalettePreview}
      keyExtractor={(item) => item.paletteName}
    />
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 10,
  },
});

這邊做了幾件事:

  1. 新增 FlatList ,傳入剛剛整理好的 COLOR_PALETTES,並寫了 renderPalettePreview ,內容就是把 item 的資料當作 props 傳進 PalettePreview
  2. 特別注意 handlePress,這邊就是把 navigation.navigate 的 callback 傳進去,第二個參數傳遞 item,而 item 是什麼呢?是 { paletteName: 'Solarized', colors: SOLARIZED } (或其他 palette)
  3. 寫 style,增加個 padding:10。

再來回到 PalettePreview.tsx,首先新增 Flatlist,並且 render View,這個 View 就是一個一個的顏色小方塊:

import React from 'react';
import {
  View,
  Text,
  FlatList,
  TouchableOpacity,
} from 'react-native';
import { TPalette } from '../utils/colors';

type TProps = {
  handlePress: () => void;
  colorPalette: TPalette;
};

function PalettePreview({ handlePress, colorPalette }: TProps) {
  return (
    <TouchableOpacity onPress={handlePress}>
      <View>
        <Text>{colorPalette.paletteName}</Text>
        <FlatList
          data={colorPalette.colors.slice(0, 5)}
          renderItem={({ item }) => (
            <View/>
          )}
          keyExtractor={(item) => item.hexCode}
        />
      </View>
    </TouchableOpacity>
  );
}

data 之所以會是 colorPalette.colors.slice(0, 5) 是因為需求說要顯示前五個顏色就好。

到這邊的時候,其實需求已經完成了大半,剩下的只是 style 而已。

Part4 - 撰寫 PalettePreview style

接下來只要把 style 套用上去就好:

import React from 'react';
import {
  View,
  Text,
  FlatList,
  StyleSheet,
  TouchableOpacity,
} from 'react-native';
import { TPalette } from '../utils/colors';

type TProps = {
  handlePress: () => void;
  colorPalette: TPalette;
};

function PalettePreview({ handlePress, colorPalette }: TProps) {
  return (
    <TouchableOpacity onPress={handlePress} style={styles.container}>
      <View>
        <Text style={styles.text}>{colorPalette.paletteName}</Text>
        <FlatList
          style={styles.colors}
          data={colorPalette.colors.slice(0, 5)}
          renderItem={({ item }) => (
            <View style={[styles.box, { backgroundColor: item.hexCode }]} />
          )}
          keyExtractor={(item) => item.hexCode}
        />
      </View>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  container: {
    marginBottom: 20,
  },
  colors: {
    flexDirection: 'row',
  },
  text: {
    fontWeight: 'bold',
    fontSize: 16,
    marginBottom: 5,
  },
  box: {
    width: 30,
    height: 30,
    marginRight: 10,
    borderRadius: 2,
  },
});

export default PalettePreview;

另外,因為可能會有方塊顏色和背景太相近,導致不是很好區別的情形,這邊就可以加上陰影:

shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 1,
elevation: 2, // Android 專屬

elevation: 2 is Android only and the rest is iOS only.

ok Exercise 完成,到這邊 Navigation 的章節就結束了。

References

React Native, v2