Site icon May's Notes

React Native 奇幻之旅(15)-React Navigation 路由傳參、監聽路由狀態

React Native logo

這是我在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 的原因:

  1. 沒辦法設置 initialRouteName
  2. 接第一點,所以如果是嵌套式 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>
  )
}

參考資料

Exit mobile version