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.tsx
和 ColorPalette.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 來當作包裹容器差不多。
接著,我們引入 Home
及 ColorPalette
兩個 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.Screen
的 name
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
。
但如果 navigation
和 route
都要使用到的話,官方是推薦改成使用 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 📝
詳細的需求看這裡: Navigation Exercise
這章節的練習有四個需求:
- update the app so that the colors and name are being passed into the ColorPalette component, making it reusable. Docs
- make sure the page title will be the name of the color palette instead of the name of the page. Docs
- add two more color schemes: Rainbow and Frontend Masters (hint: you create a
COLOR_PALETTES
array and use aFlatList
to render them) - 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' },
];
示意圖:
Navigation Exercise Solution 👀
我們就來逐步完成它吧!
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,
},
});
這邊做了幾件事:
- 新增
FlatList
,傳入剛剛整理好的COLOR_PALETTES
,並寫了renderPalettePreview
,內容就是把 item 的資料當作 props 傳進PalettePreview
。 - 特別注意
handlePress
,這邊就是把navigation.navigate
的 callback 傳進去,第二個參數傳遞item
,而 item 是什麼呢?是{ paletteName: 'Solarized', colors: SOLARIZED }
(或其他 palette) - 寫 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 的章節就結束了。