ushumpei’s blog

生活で気になったことを随時調べて書いていきます。

React Navigationで画面遷移してみる

React Nativeで画面遷移したかったのでまとめました。

内容としてはcreate-react-native-appで作ったアプリで、2つのナビゲーター(StackNavigatorTabNavigator)を使ってみた勉強記事です。

注意としてはDrawerNavigatorは使わないことと、redux等との連携も書いてないことです。

記述方法等は公式ドキュメントをご確認ください。

概要

公式ドキュメント で紹介されている画面遷移用のライブラリはいくつかありますが、 クロスプラットフォームで動作するものと考えると選択肢に入るのは以下の二つかなと思いました。 二つとも綺麗なドキュメントが用意されています。

どちらを選んでもよかったのですが、 create-react-native-appで使ったアプリを書きたいと思ったので、 javascriptだけで書かれているReact Navigationで書くことにしました。

React Navigation

React NavigationはReact Nativeで画面遷移を実装するために、いくつかのナビゲーター(navigator)というコンポーネントを利用します。

ナビゲーターはルーティングしたり画面に引数をあげたりジェスチャーを制御したり、画面遷移に必要なものを提供してくれるものです(ざっくりです)。

標準で使えるナビゲーターとしては、StackNavigatorTabNavigatorDrawerNavigatorです。それぞれ以下のような特徴があります。

  • StackNavigator: 画面を積んでいくタイプのナビゲーター。Screen1 -> Screen2とかScreen1 -> Screen3とか画面を積んでツリーを作っていき、行きつ戻りつしていくという感じです。描画した画面内のコンポーネントを押したら別の画面に遷移させる、といったWebのリンクと同じようなケースで使う感じです。
  • TabNavigator: タブタイプのナビゲーター。[Screen1、Screen2、Screen3]のように並列に画面を依存させずに扱い、切り替えていく感じです。画面遷移はTabNavigator自身が責任を持つ感じです。
  • DrawerNavigator: ドロワータイプのナビゲーター。自分的にはタブが横なら、ドロワーは縦という程度の位置が違うくらいの認識です。ただ、タブに比べて登録できる画面数が多い(多くても比較的わかりにくくならない)ので、例えばユーザーによって画面数が変化するタイプのものであれば、これを使うのかなと思います。

通常はこれらをネストさせるなどしてアプリケーションの画面遷移を作っていきます。カスタムしたナビゲーターを作ることもできるようですが、まだいいかなと思います。(丸いナビゲーターとかARっぽくてカッコ良さそう)

初心者的にはネストさせ方がちょっと不明で困ります。よく見るアプリケーションでは、

  • TabNavigator(下タブ) or DrawerNavigatorをルートの親ナビゲーターとして選ぶ
  • それぞれの子にScreen(普通の画面)、StackNavigator、TabNavigator(上タブ)を任意に登録
  • そのそれぞれの子に画面を登録

のネストが2段くらいの構成になっていると思いますのでそれを真似します。

疑問: 認証、設定などの画面はどのように制御するのがいいのでしょうか?設定画面はモーダルを使って表示するのはよく見る気がします?認証画面もモーダルを使った表示を見ることがありますが、一瞬アプリが表示されてからシュルッと画面を乗っけるとかだと、裏側で画面が動かないように制御しなければいけない気がします。トップレベルのに認証用のナビゲーターをかませてネストを3段にするとか?

追記 2018/05/07: SwitchNavigator 使うといいかもしれないです

準備

ドキュメントを参考にインストールします。

$ create-react-native-app demo-application
$ cd demo-application
$ npm install --save react-navigation

StackNavigator

単純な使い方として、リストを表示して、押したらその詳細画面に遷移する例を書きます。

自動生成されるApp.jsに以下のコードを記述します。

gist.github.com


StackNavigator

という感じになりました。今回は単純な例なので遷移先のDetailは使い回しです。

TabNavigator

2つの画面を持つTabNavigatorを作成します。

  • App
    • MathematicsList: 先ほど作った数学リスト
    • AddMathItemScreen: 数学項目追加画面

やることは3つです、

  1. TabNavigatorのコンポーネントを作成
  2. 項目追加機能の実装
  3. 項目削除機能の実装

Step1 - TabNavigatorのコンポーネントを作成

App.jsを修正してTabNavigatorを描画するコンテナーコンポーネントを作成していきます。

...
import { TabNavigator } from 'react-navigation'; /* 追加 */

/*
 * TabNavigatorを作成
 * StackNavigatorと基本は同じ
 * 第二引数で画面下タブに表示されるアイコン色とラベル非表示を設定
 */
const Tab = TabNavigator({
  List: { screen: MathematicsList },
  AddItem: { screen: AddMathItemScreen },
}, {
  tabBarOptions: {
    activeTintColor: '#037aff',
    inactiveTintColor: '#737373',
    showLabel: false,
  },
});
/*
 * 二つのタブでデータを共有するためにAppコンポーネントをコンテナコンポーネント化
 * 今の所stateを持っている以外はTabNavigatorをラップしているだけ
 */
export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      mathematics,
    };
  }

  render() {
    /* screenPropsで各子供にmathematicsを渡している */
    return <Tab screenProps={{ mathematics: this.state.mathematics }} />;
  }
}

TabNavigatorStackNavigatorコンポーネントなのでjsx記法でrenderに書くことができます。screenPropsに値を入れると各タブでデータを受け取ることができるようです。ここでは各タブにmathematicsデータを渡すために、TabNavigatorの設定をして一旦Tabと名付けた後に、Appコンポーネントをコンテナ化し内部でscreenPropsを渡しました。

TabNavigatorで登録された画面にnavigationOptionsを設定していきます。AddMathItemScreenは以下のように作成しました。

...
import { Entypo } from '@expo/vector-icons';
...
/*
 * 項目追加画面を作成
 * 現在はタブアイコンの設定のみのモック
 */
const AddMathItemScreen = () => (
  <View style={styles.container}>
    <Text style={styles.paragraph}>This is AddMathItemScreen</Text>
  </View>
);
AddMathItemScreen.navigationOptions = {
  tabBarIcon: ({ tintColor }) => <Entypo size={24} name="add-to-list" color={tintColor} />,
};

tabBarIcontintColorTabNavigatorのオプションで設定していて、activeinactiveに応じて異なる値が送られてきます。Entypocreate-react-native-appで作成すると初めから使えるexpoライブラリに含まれているカスタムフォントのコンポーネントです。

AddMathItemScreenと同様にMathematicsListにもタブアイコンの設定を行います。

/*
 * StackNavigatorを変数に格納
 */
const Stack = StackNavigator({
  Detail: { screen: DetailScreen },
  List: { screen: ListScreen },
}, {
  initialRouteName: 'List',
});
/*
 * StackNavigatorをラップするコンポーネント
 * screenPropsにより親からもらったpropsを子にそのまま流している(手抜き)
 * mathematicsリストをAppコンポーネントに持たせているため、
 * ここで設定したものがListScreenに渡っていく。
 */
const MathematicsList = ({ screenProps }) => (
  <Stack screenProps={screenProps} />
);
/*
 * タブアイコンの設定
 */
MathematicsList.navigationOptions = {
  tabBarIcon: ({ tintColor }) => <Entypo size={24} name="list" color={tintColor} />,
};

また、親からscreenPropsが渡されてくるのでそれをListScreenに渡すためにここでも一旦StackNavigatorを変数に格納して、MathematicsList内で受け渡しを行います。(もっといい方法があると思うんですが。。。ちゃんとやるときは多分reduxとか使うのでこの辺りはなあなあにしておきます)

ちなみにListScreenは次のように値を受け取ります。

/* 引数screenPropsが親から渡ってくる */
const ListScreen = ({ navigation, screenProps }) => (
  <FlatList
    data={screenProps.mathematics} /* mathematicsを取り出す */
...


TabNavigator1

上記の変更をまとめたのが以下になります。

gist.github.com

2つのタブが画面下に表示され、片方の画面は先ほどのStackNavigatorがネストされている状態になっています。

追記: タブアイコンの設定箇所をTabNavigatorの定義箇所に変更することも可能なようです。StackNavigatorに関しても同様で、タイトルなど動的に変更する必要があるかどうかで分けるという感じです。

const Tab = TabNavigator({
  List: {
    screen: MathematicsList,
    navigationOptions: {
      tabBarIcon: ({ tintColor }) => <Entypo size={24} name="list" color={tintColor} />,
    },
  },
  AddItem: {
    screen: AddMathItemScreen,
    navigationOptions: {
      tabBarIcon: ({ tintColor }) => <Entypo size={24} name="add-to-list" color={tintColor} />,
    },
  },
}, {
  initialRouteName: 'List',
  tabBarOptions: {
    activeTintColor: '#037aff',
    inactiveTintColor: '#737373',
    showLabel: false,
  },
});

Step2 - 項目追加機能の実装

項目追加機能を実装するために、Appコンテナコンポーネントからstateをいじれる関数を、TabNavigator経由でAddMathItemScreenコンポーネントまで渡します。

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      mathematics,
    };
    this.addNewMathItem = this.addNewMathItem.bind(this);
  }

  /* 子供に渡す関数を作成 */
  addNewMathItem({ title, detail }) {
    this.setState({
      mathematics: [...this.state.mathematics, { title, detail }],
    });
  }

  render() {
    /* screenPropsで各子供にmathematics、addNewMathItemを渡す */
    return <Tab screenProps={{ mathematics: this.state.mathematics, addNewMathItem: this.addNewMathItem }} />;
  }
}

受け取ったAddMathItemScreen側でタイトル、詳細のテキスト入力、追加リクエスト処理を記述します。追加が完了したらListScreenに遷移するようにAlertOKボタンをトリガーに設定します。見栄えに関して多少のスタイルも追加しています。

class AddMathItemScreen extends React.Component {
  constructor(props) {
    super(props);
    this.state = { title: '', detail: '' };
    this.handleOnPress = this.handleOnPress.bind(this);
  }

  handleOnPress() {
    const { title, detail } = this.state;
    if (!title) return Alert.alert('Error', 'titleは必須です');

    this.props.screenProps.addNewMathItem({ title, detail });
    Alert.alert(
      'Notice',
      '項目を追加しました!',
      [{ text: 'OK', onPress: () => this.props.navigation.navigate('List') }],
    );
    this.setState({ title: '', detail: '' });
  }

  render() {
    return (
      <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
        <View style={styles.container}>
          <View style={styles.inputGroup}>
            <Text style={[styles.paragraph, styles.label]}>title</Text>
            <TextInput
              blurOnSubmit
              onChangeText={title => this.setState({ title })}
              style={[styles.textInput, styles.heading]}
              value={this.state.title}
            />
          </View>
          <View style={styles.inputGroup}>
            <Text style={[styles.paragraph, styles.label]}>detail</Text>
            <TextInput
              blurOnSubmit
              multiline
              onChangeText={detail => this.setState({ detail })}
              style={[styles.multiTextInput, styles.paragraph]}
              value={this.state.detail}
            />
          </View>
          <Button onPress={this.handleOnPress} title={'Add item to list'} />
        </View>
      </TouchableWithoutFeedback>
    );
  }
}


TabNavigator2

まとめると次のようになっています。

gist.github.com

Step3 - 項目削除機能の実装

項目追加機能同様、Appコンテナコンポーネントからstateをいじれる関数を渡します。削除の方法としては、配列のインデックスをキーに各詳細画面の削除ボタンから実行します(画面遷移で操作を限定させないと容易にバグりそうですね、理詰めでバグらせられそう)

削除メソッドを追加し、screenPropsで渡します。

export default class App extends React.Component {
...
  removeMathItem(index) {
    this.setState({
      mathematics: this.state.mathematics.filter((_, i) => i !== index),
    });
  }
...
  render() {
    /* screenPropsで各子供に渡している */
    return (
      <Tab
        screenProps={{
          mathematics: this.state.mathematics,
          addNewMathItem: this.addNewMathItem,
          removeMathItem: this.removeMathItem,
        }}
      />
    );
  }

次にDetailScreenのヘッダー右上に削除ボタンを設置する設定を書きます。indexListScreenからの遷移時にnavigation.state.params経由で渡すようにし、removeMathItemscreenProps経由で渡したのでそれらから取得できます。(ListScreenの変更は最後のまとまったもので確認できます)

DetailScreen.navigationOptions = ({ navigation, screenProps }) => ({
  headerRight: (
    <TouchableOpacity
      style={{ marginRight: 8 }}
      onPress={() => {
        Alert.alert(
          'Warning',
          '項目を削除しますか?',
          [
            {
              text: 'Delete',
              onPress: () => {
                screenProps.removeMathItem(navigation.state.params.index);
                navigation.goBack();
              },
            },
            { text: 'Cancel' },
          ],
        );
      }}
    >
      <Entypo size={24} name="trash" color={'red'} />
    </TouchableOpacity>
  ),
});

以上で削除機能が実装できました。コード汚いよ、コンポーネントに切り出せよ、と思いますが指針が決まらないのでとりあえず画面単位の分割に抑えています。

完成

完成しました。こんな感じで動いています。


TabNavigator3

gist.github.com

感想

引数の受け渡しはReduxとかでやりたいと思いました。なのでReduxない状態でのプロパティの渡し方は勉強不足です。すみません。

あと書き捨てのつもりだったのでgit管理していなくて、diffだけ見せるとかいう方法もあったかもという気がします。さらによくよく考えればgistの差分でもよかったです。記事が読みにくい感じですね。