RIT Tech Blog

株式会社RITのエンジニアが知見を共有する技術ブログです。

React Navigationで遷移元に応じて戻る先を変える

React Navigationで、ネストされたStackの遷移元によって戻る先を出し分けたい場合があったので、その対処法を記事にしました。

環境

  • Expo
  • TypeScript
  • ReactNavigation

セットアップ

型安全なプロジェクトを作成します

  • expoプロジェクトを作成
$ expo init navigation-test
  • テンプレートで、blank (Typescript)を選択
  • 必要なパッケージをインストール
$ yarn add @react-navigation/native @react-navigation/native-stack
$ expo install react-native-screens react-native-safe-area-context
  • 公式を参考に以下を実装
// App.tsx
import { NavigationContainer, useNavigation } from '@react-navigation/native';
import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack';
import React from 'react';
import { Button, Text, View } from 'react-native';
import { RootStackParamList } from './types';

const RootStack = createNativeStackNavigator<RootStackParamList>();

export default function App() {
  return (
    <NavigationContainer>
      <RootStack.Navigator initialRouteName="A">
        <RootStack.Screen name="A" component={AScreen}/>
        <RootStack.Screen name="B" component={BScreen}/>
        <RootStack.Screen name="C" component={CScreen}/>
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

const AScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>A Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}
const BScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>B Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}
const CScreen: React.FC = () => {
  const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  return (
    <View>
      <Text>C Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        onPress={() => rootNavigation.navigate('B')} 
      />
    </View>
  )
}

// types.ts
export type RootStackParamList = {
  A: undefined;
  B: undefined;
  C: undefined;
}

簡単なコードの解説

const RootStack = createNativeStackNavigator<RootStackParamList>();
  • 公式にならって、createNativeStackNavigatorのジェネリック型にStackが扱うScreenのパラメータの型を渡して、Stackを定義しています
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
  • Stackの型を渡して、rootNavigationを定義しています。rootNavigationをconsole.logすると、画面遷移に関係する様々なメソッドが取れます↓
Object {
  "addListener": [Function addListener],
  "canGoBack": [Function canGoBack],
  "dispatch": [Function dispatch],
  "getId": [Function getId],
  "getParent": [Function getParent],
  "getState": [Function anonymous],
  "goBack": [Function anonymous],
  "isFocused": [Function isFocused],
  "navigate": [Function anonymous],
  "pop": [Function anonymous],
  "popToTop": [Function anonymous],
  "push": [Function anonymous],
  "removeListener": [Function removeListener],
  "replace": [Function anonymous],
  "reset": [Function anonymous],
  "setOptions": [Function setOptions],
  "setParams": [Function anonymous],
}
  • この中のgetStateメソッドのroutesで、過去に積まれたscreenの履歴を見れるので、Screen A、B、Cを作成して、そこで積まれているroutesを画面に表示しています
  • 各画面のナビゲーション先は、一旦全てBにしています

    基本的なnavigationの挙動確認

AからBに画面遷移する

A→B→A

  • AからB遷移時に、routesに新しいscreeen Bが積まれる
  • 戻るをタップすると、一つ前の画面Aに戻り、screeen Bが削除される
  • 内部的には、navigation.goBack()が発火していて、routesに積まれている一つ前のscreen Aにnavigate(’A’)している

BからBに遷移する

B→B

  • routesにすでにBが積まれていて、現在Bにいるため動かない

Bから新しいBに遷移する

  • ただし、Bへの遷移をnavigateではなく、pushメソッドを使うことで新たなscreenをroutesに登録、画面遷移できます
// B Screen
    <View>
      <Text>B Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To New B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.push('B')} 
      />
    </View>

B→NewB→B

積まれていないrouteに戻る

  • ここでBから、routesに積まれていないCに戻ったらどうなるでしょうか?
  • BScreenに以下のコードを追記して、戻るボタンをカスタマイズします
// B Screen
  useFocusEffect(
    useCallback(() => {
      rootNavigation.setOptions({
        headerLeft: () => (
          <Button 
            title="Go Back C Screen" 
            onPress={() => rootNavigation.navigate('C')} 
          />
        ),
      });
    }, []),
  );

A→B→NewB→C

  • routeにCが存在しないので、新たなScreenCがroutesに積まれて画面遷移するのがわかります

遷移元により戻る先を変動させる

  • 長くなりましたがここから本題です。これらのStackはネストすることができるのですが、異なるStack間の画面遷移時に意図した通りのroutesが積まれず、navigationに用意されているメソッドでは理想とする画面遷移を実現できないケースが出てきました。
  • 例えば以下の構成で、HelpからHelpDetailに画面遷移した後はHelpに戻りたい一方、UserからHelpDetailに遷移した場合はUserに戻りたい場合、UserとHelpDetailは異なるStackに属するのでgoBackができないなど問題が発生しました。

  • RootStack

    • Home
      • TabNavigator
        • HelpStack
          • Help
          • HelpDetail
        • XXXStack
    • User
    • XXX
  • 対処法としては、HelpDetailにパラメータを渡して、そのパラメータによって戻り先を変更することができます。

Aからの遷移だった場合のみCに遷移させる

  • これまでの例で見ていきます。
  • BにAからの遷移であるかを示すパラメータisFromAを渡します。
// types.ts
export type RootStackParamList = {
  A: undefined;
  // B: undefined;
  B: { isFromA: boolean };
  C: undefined;
}

// AScreen
    <View>
      <Text>A Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.navigate('B', { isFromA: true })} 
      />
    </View>
  • useRouteを使って、Bに渡されるパラメータを取得できます
// B Screen
  const { params } = useRoute<RouteProp<RootStackParamList, 'B'>>()
  • isFromAがtrueの時のみ、戻り先をCに固定します
  useFocusEffect(
    useCallback(() => {
      rootNavigation.setOptions({
        // headerLeft: () => (
        //   <Button 
        //     title="Go Back C Screen" 
        //     onPress={() => rootNavigation.navigate('C')} 
        //   />
        // ),
        headerLeft: () => (
          params.isFromA ? (
            <Button 
              title="Go Back C Screen" 
              onPress={() => rootNavigation.navigate('C')} 
            />
          ) : (
            <Button 
              title="Go Back" 
              onPress={() => rootNavigation.goBack()} 
            />
          )
        )
      });
    }, []),
  );

挙動確認

A→B→C

  • routesにCが存在しないため、新たなscreenCが積まれて画面遷移してはいますが、遷移元がAのとき正しくCに戻れています

  • 最後にCからBに遷移したときの挙動確認です。初期表示するScreenを設定できるinitailRoutesをCに変更して検証します。

// App.tsx
export default function App() {
  return (
    <NavigationContainer>
      // <RootStack.Navigator initialRouteName="A">
      <RootStack.Navigator initialRouteName="C">
        <RootStack.Screen name="A" component={AScreen}/>
        <RootStack.Screen name="B" component={BScreen}/>
        <RootStack.Screen name="C" component={CScreen}/>
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

// C Screen
    <View>
      <Text>C Screen</Text>
      <Text>{JSON.stringify(rootNavigation.getState().routes)}</Text>
      <Button 
        title="Go To B Screen" 
        // onPress={() => rootNavigation.navigate('B')} 
        onPress={() => rootNavigation.navigate('B', { isFromA: false })} 
      />
    </View>
  )

C→B→C

  • 遷移元がCのときは正しくCに戻れることが確認できました!

参考

React NavigationをTypescriptと一緒に使う際につまづいたところ - kmgk's blog

https://reactnavigation.org/docs/typescript/