這是我在2023第十五屆 iThome 鐵人賽發表的系列文章。https://ithelp.ithome.com.tw/users/20136637/ironman/6408
今天這篇會分享一些 navigation 小技巧。
Grouping
React Navigation 6.x 版本新增了一個 Group 的組件,用於將 Screen 分組。
<Stack.Navigator>
<Stack.Group
screenOptions={{ headerStyle: { backgroundColor: 'papayawhip' } }}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Group>
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen name="Search" component={SearchScreen} />
<Stack.Screen name="Share" component={ShareScreen} />
</Stack.Group>
</Stack.Navigator>
一開始看見 Group 我想的是可以取代之前 Stack 嵌套的寫法,能讓程式碼看著更簡潔一點:
<NavigationContainer>
<Stack.Navigator initialRouteName="LoginStack">
<Stack.Screen
name="LoginStack"
component={LoginStackNavigator}
options={{ headerShown: false }}
/>
<Stack.Screen
name="BottomTabStack"
component={BottomTabNavigator}
options={{ headerShown: false }}
/>
</Stack.Navigator>
</NavigationContainer>
export const LoginStackNavigator = () => {
return (
<Stack.Group screenOptions={{ headerShown: false }}>
<Stack.Screen
name="Login"
component={LoginScreen}
/>
<Stack.Screen
name="Register"
component={RegisterScreen}
/>
<Stack.Screen
name="ForgotPassword"
component={ForgotPasswordScreen}
/>
<Stack.Screen
name="ConfirmSignUp"
component={ConfirmSignUpScreen}
/>
</Stack.Group>
)
}
但其實它並不是用來處理這個的,而是用於將相同 screenOptions 的頁面分成一組。
無法用 Group 取代 Navigator 的原因:
- 沒辦法設置 initialRouteName
- 接第一點,所以如果是嵌套式 Navigator 打開應用就是空白,因為不知道去哪
只適合用在導航沒嵌套並且有需要設置相同 screenOptions 的情況。
const App = () => {
return (
<Stack.Navigator ...>
<Stack.Screen ...>
<Stack.Group>
<Stack.Screen ... />
<Stack.Screen ... />
</Stack.Group>
</Stack.Navigator>
)
}
export default App
Params
設置當前頁面參數
navigation.setParams
設的參數只有當前頁面能拿到,並且是非同步,所以 setParams 後直接 log 出來的結果會是 undefined(如果之前就有設過該參數那就是保持舊的值)
const onPress = () => {
navigation.setParams({ email: 'test@gmail.com' })
console.log(route.params) // undefined
}
如果要隨時監聽參數的變化可以用 useEffect:
useEffect(() => {
if (Object.keys(route.params ?? {}).length > 0) {
console.log(route.params)
}
}, [route.params])
傳遞參數給其他頁面
// 一般寫法
navigation.navigate('ConfirmSignUp', {
email: 'test@gmail.com',
password: '123456'
})
// 嵌套式Navigator
navigation.navigate('LoginStack', {
screen: 'ConfirmSignUp',
params: {
email: 'test@gmail.com',
password: '123456'
}
})
ConfirmSignUp 頁面一樣使用 route.params
就能拿到傳過去的參數。
但要注意的是如果不清除的話這些參數是會一直留著直到關閉App,那要如何在離開頁面的時候清除這些舊的參數呢?
這就需要使用 navigation.addListener
監聽是否離開頁面,離開之前先將參數重設之後再離開:
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
// 防止默認的返回操作
e.preventDefault()
// 重設參數
navigation.setParams({ email: '', password: '' })
// 離開頁面
navigation.dispatch(e.data.action)
})
return unsubscribe
}, [navigation])
這邊不能使用 blur 的原因是,blur 是頁面失去焦點時會觸發,所以就算沒有離開頁面只是切屏也會觸發 blur,這不是我們希望的效果。
監聽頁面狀態
有幾種監聽頁面狀態的方式:
1.使用事件監聽器 navigation.addListener('focus', () => {})
useEffect(() => {
const unsubscribe = navigation.addListener('focus', async () => {
console.log('Hello')
})
return unsubscribe
}, [navigation])
2.useFocusEffect
3.useIsFocused
useFocusEffect
navigation.addListener('focus', () => {})
跟 useFocusEffect
其實是一樣的效果,只不過useFocusEffect是5.x之後才出的hook,useFocusEffect 會在頁面失焦時自動清除監聽器:
import React, { useState, useCallback } from 'react'
import { useFocusEffect } from '@react-navigation/native'
const Demo({ userId }) {
const [user, setUser] = useState(null)
useFocusEffect(
useCallback(() => {
console.log('Hello')
}, [userId])
);
// ...
}
useIsFocused
useIsFocused 會回傳的是 boolean,代表現在頁面是否被聚焦。
比較特別的是這個 hook 是用於希望 re-render 頁面才使用,我經常把它和相機組件一起使用,因為在 Tabs 之間切換時 React navigation 不會卸載組件,所以離開後再次返回帶有相機組件的頁面時只會看到一片黑,這個時候就可以用 useIsFocused 這個 hook。
import { useIsFocused } from '@react-navigation/native'
import { Camera, CameraType } from 'expo-camera'
const Scanner = () => {
const isFocused = useIsFocused()
const [permission, requestPermission] = Camera.useCameraPermissions();
if (!permission) ...
if (!permission.granted) ...
return (
<>
{isFocused && (
<Camera
type={CameraType.back}
style={{ flex: 1 }}
// ...
/>
)}
</>
)
}
實作:確認是否退出App提示
Android
Android 可以使用 RN 內建的 BackHandler API 監聽使用者是否按了返回鍵,然後跳出退出提示:
import React, { useEffect } from 'react'
import { BackHandler, Alert } from 'react-native'
const backAction = () => {
Alert.alert('注意!', '確定真的要離開App?', [
{
text: '取消',
onPress: () => null,
style: 'cancel',
},
{
text: '確定',
onPress: () => BackHandler.exitApp()
}
])
return true
}
const App = () => {
useEffect(() => {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
backAction,
)
return () => backHandler.remove()
}, []);
// ...
}
export default App
但這樣做有一個缺點是每次使用者按下返回鍵都會觸發,正常情況應該是返回到沒有上一頁可以返回時才跳出提示,所以我們需要再去判斷是不是已經沒有上一頁了。
React Navigation 有提供一個 useNavigation
hook,有提供現成的函數navigation.canGoBack()
判斷是否還有上一頁,完整寫法如下:
import { useEffect } from 'react'
import { BackHandler, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { createStackNavigator } from '@react-navigation/stack'
import { BottomTabNavigator } from './BottomTabNavigator'
import { LoginStackNavigator } from './LoginStackNavigator'
const Stack = createStackNavigator()
export const RootNavigator = () => {
const navigation = useNavigation()
const backAction = () => {
if (navigation.canGoBack()) {
return false
}
Alert.alert('注意!', '確定真的要離開App?', [
{
text: '取消',
onPress: () => null,
style: 'cancel',
},
{
text: '確定',
onPress: () => BackHandler.exitApp()
}
])
return true
}
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction)
return () => backHandler.remove()
}, [])
return (
<Stack.Navigator initialRouteName="LoginStack">
<Stack.Screen
name="LoginStack"
component={LoginStackNavigator}
options={{ headerShown: false }}
/>
<Stack.Screen
name="BottomTabStack"
component={BottomTabNavigator}
options={{ headerShown: false }}
/>
</Stack.Navigator>
)
}
const App = () => {
return (
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
)
}
export default App
注意事項:如果當前有 Modal 打開時按下返回鍵並不會觸發 BackHandler。
iOS
iOS 沒有返回鍵,返回上一頁通常是使用手勢,所以首先要先讓 Navigator 支持手勢操作:
<Stack.Navigator
screenOptions={{
gestureEnabled: true,
gestureDirection: 'horizontal',
}}
>
// ...
</Stack.Navigator>
然後監聽 beforeRemove 事件,如果當前路由已經沒辦法再繼續返回就跳出關閉APP提示:
import { useEffect } from 'react'
import { Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { createStackNavigator } from '@react-navigation/stack'
import { BottomTabNavigator } from './BottomTabNavigator'
import { LoginStackNavigator } from './LoginStackNavigator'
const Stack = createStackNavigator()
export const RootNavigator = (): JSX.Element => {
const navigation = useNavigation()
const backAction = () => {
if (navigation.canGoBack()) {
return false
}
Alert.alert('注意!', '確定真的要離開App?', [
{
text: '取消',
onPress: () => null,
style: 'cancel',
},
{
text: '確定',
onPress: () => BackHandler.exitApp()
}
])
return true
}
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', backAction)
return () => unsubscribe()
}, [])
return (
<Stack.Navigator initialRouteName="LoginStack">
<Stack.Screen
name="LoginStack"
component={LoginStackNavigator}
options={{ headerShown: false }}
/>
<Stack.Screen
name="BottomTabStack"
component={BottomTabNavigator}
options={{ headerShown: false }}
/>
</Stack.Navigator>
)
}