JavaScript
Android
UI
iOS
reactnative

ReactNative+Redux+NativeBaseでつくるサンプル実装をのぞく

1. はじめに

個人的にReactNativeを(片手間で)触り初めてから約1年が経過しまして、その際にもう一度始めたばかりの時になかなか理解ができなかった点を中心に復習をしてみた際の記録を改めて自分なりにまとめてみることにしました。また補足として現在の本業(iOSでSwift開発)にも活かすことができた部分についても少し紹介ができれば幸いに思います。

※ すでにReactNativeを活用している方には物足りない内容かもしれませんが、何卒ご容赦下さい。

※ 上記のサンプルに関しても手を入れた場合には追記するように致します。

今回の記事のポイントとなる部分を「React Native Meetup #8」にて発表する機会がありましたので、その際に使用したスライドもここに共有致します。

このスライド内では今回の記事に関する概要とポイントになる部分をまとめていますので、皆様のご理解の参考となれば幸いに思います。

※ こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

2. 今回の参考アプリとサンプル概要について

今回のサンプルについては、「ReactNative + Redux + NativeBase + Firebase」を組み合わせたサンプルではありますが、それ以外のUI表現等に関するライブラリはできるだけ用いずにシンプルかつ必要最小限の実装構成にしています。またUI実装に関してはできるだけNativeBaseのComponentが提供するデザインに合わせています。

★2-1. サンプルのキャプチャ画面

サンプルのキャプチャ画像1:

capture1.png

サンプルのキャプチャ画像2:

capture2.png

★2-2. 使用しているライブラリの一覧とバージョン

今回のサンプルでのReact及びReactNativeのバージョンは下記の通りになります。(現行の最新バージョンより少し古いです...汗)

  • ReactNative: 0.54.2
  • React: 16.3.0-alpha.2

また今回のサンプルでNativeBase以外で使用している主なライブラリの一覧は下記のようになります。

1. DBや遷移などアプリのベースになる機能:

ライブラリ名 ライブラリの機能概要 バージョン
react-native-firebase 認証&DB機能 5.0.3
react-native-router-flux 画面遷移のコントロール 4.0.0-beta.28

2. UIやデザイン要素のベースになる機能:

ライブラリ名 ライブラリの機能概要 バージョン
NativeBase UIコンポーネント 2.4.4
react-native-app-intro-slider 初回チュートリアル画面 0.2.4

3. Reduxを導入するにあたって必要なもの:

ライブラリ名 ライブラリの機能概要 バージョン
redux タブ型のコンテンツ切り替え 4.0.0
react-redux ComponentとReduxの接続 5.0.7
redux-thunk 非同期通信 2.2.0
redux-logger stateのログ出力 3.0.6

4. ユニットテストを実行するとライブラリに起因するエラーが出る場合

jestでユニットテスト(ReduxのテストやComponentのスナップショットテスト)を行う場合には、package.json内のtransformIgnorePatternsの部分に下記の追記をするとテストが通るようになります。

package.json
"jest": {
  "preset": "react-native",
  "transformIgnorePatterns": [
    "/node_modules/(?!native-base|react-native-router-flux)/"
  ]
}

NativeBaseやreact-native-router-flux等のライブラリを導入した際に、ライブラリによってはjestの実行に影響が出る場合があるのでユニットテストを利用する場合には、transformIgnorePatternsにて該当ライブラリを除く処理をしておくと良いと思います。

3. 全体的な画面の遷移フローに関する部分

今回のサンプルにおける全体的な画面遷移の構成や画面の振る舞いに関する部分についてまとめていきます。このサンプルの画面数はさほど多くはありませんが、画面を構成するComponentの設計やコンテンツを切り替えるタイミングの定義等にも関わってくる部分になるので、他にも様々な方法が考えうると思いますが、参考になれば幸いに思います。
(小規模なアプリならこの様な構成でも良いかもしれませんが、規模が大きいものだとこれではつらいと思います...)

★3-1. 画面遷移やデータとの連結に関する全体的なイメージ

サンプル全体の画面遷移図とどの様なコンテンツ表示があるかをまとめると、下記の様な形のフローになります。
最初にログインないしは新規登録画面を経た後に、コンテンツの表示やデータのCRUD処理を行う画面を表示する構成になっています。

サンプル全体の画面構成図:

contents_mapping.png

DrawerやTabBarによるコンテンツの切り替えによるComponentの選択状態の考慮も行うようにしています。

Firebaseのデータ構造と対応する画面について:

このサンプルにおいては「ユーザー認証部分 & ユーザーの入力データ及びコンテンツ用表示データ」の管理については、Firebaseを利用しています。
特別に複雑な処理やデータ構造定義は行ってはいませんが、このような感じのデータ構造を想定して作成しました。
(Firebaseを利用するときにはセキュリティ的な点からも、しっかりとruleの設定を行うようにしてください)

(1) コンテンツ表示用のデータの構成

read_only_data.png

(2) ユーザーがCRUD処理をするデータの構成

user_write_data.png

(3) データと画面の対応に関する図

data_mapping_point.png

またそれぞれの画面においてFirebaseで取得したデータに関しては、Reduxを介した処理を経て画面に表示するような形になります。

★3-2. react-native-router-fluxを利用した画面遷移とReduxを利用したView状態管理の兼ね合い

このサンプルではアプリ内の画面遷移については、react-native-router-fluxの処理や構成にできるだけ沿うような形の構成にしています。
今回このライブラリを使用するに至ったポイントとしては、

  1. 画面遷移時のアニメーションがiOS/Android純正の動きに近かった
  2. 画面遷移の処理が記述しやすく、柔軟な画面遷移にも対応しやすそうだった
  3. ReduxのActionCreator実行時の処理と合わせる際に相性が良さそうに感じた

の3点でした。

基本的なそれぞれの画面表示の定義と画面遷移に関する処理は下記のような形になります。

1. 下記のような画面のルーティングをApp.jsなどに定義する

<Router>
  <Scene key="root">
    <Scene key="main">
      <Scene key="ComponetA" component={ComponetA} initial={true} />
      <Scene key="ComponetB" component={ComponetB} />
    </Scene>
  </Scene>
</Router>
※ 一番最初に表示されるのはComponetAとなる。

2. 遷移元からComponentBへ進む

・遷移先のComponentへ送る値がある場合 → Actions.ComponetB({ id: item.id, title: item.title, content: item.content });
・遷移先のComponentへ送る値がない場合 → Actions.ComponetB();
※ 送った値は該当のComponentBのthis.propsに格納された状態になる

3. ComponentBから遷移元へ戻る

Actions.pop();

またreact-native-router-fluxの概要や実現できる遷移処理の概要を理解する上で下記の資料も参考になりました。

参考:

今回のサンプルにおける画面遷移とRedux関連処理の概要:

view_and_routing.png

今回のサンプルを作成するにあたっては、画面遷移に関わる部分に関してはできるだけReduxを介した処理とはなるべく独立させるような形にして、Reduxで扱うものはできるだけComponentへ表示したいデータ入出力のハンドリングを伴う様な場面で使用するようにしています。
サンプルのルーティング定義やRedux・Firebaseの初期設定に関してはApp.jsに下記のように記載しています。

App.js
import React, { Component } from 'react';

// React-Reduxのインポート宣言 → ProviderタグでラップすることでReactコンポーネント内でStoreにアクセスできるようにする
import { Provider } from 'react-redux';

// createStore, applyMiddlewareのインポート宣言
import { createStore, applyMiddleware } from 'redux';

// redux-thunkのインポート宣言
import ReduxThunk from 'redux-thunk';

// redux-loggerのインポート宣言
import logger from 'redux-logger';

// react-native-router-fluxのインポート宣言(Router, Sceneを使用)
import { Router, Scene } from 'react-native-router-flux';

// firebaseのインポート宣言を行う
import firebase from 'firebase';

// reducerのインポート宣言
import reducers from './redux/reducers';

// configのインポート宣言 ※config/index.jsを作成し下記を定義(Firebaseから持ってくる値)
import { 
  API_KEY,
  AUTH_DOMAIN,
  DATABASE_URL,
  STORAGE_BUCKET,
  MESSAGING_SENDER_ID 
} from './config';

// 各画面表示用のそれぞれのコンポーネント
import TutorialContents from './components/contents/TutorialContents';
import AuthContents from './components/contents/AuthContents';
import GlobalTabContents from './components/contents/GlobalTabContents';
import RecordRequestContents from './components/contents/RecordRequestContents';
import SettingContents from './components/contents/SettingContents';
import WebViewContents from './components/contents/WebViewContents';

export default class App extends React.Component {

  // - Component Life Cycles

  // コンポーネントの内容がMountされる前に行う処理
  componentWillMount() {

    // firebaseのセッティング情報を記載する
    // ※API情報に関してFirebaseコンソールを取得 → Authentication → 「ログイン方法」でメール/パスワードを有効にする
    const config = {
      apiKey: API_KEY,
      authDomain: AUTH_DOMAIN,
      databaseURL: DATABASE_URL,
      storageBucket: STORAGE_BUCKET,
      messagingSenderId: MESSAGING_SENDER_ID
    };

    // firebaseを有効にする
    firebase.initializeApp(config);
  }

  // - Rendering Components

  render() {

    // Redux本来のdispatch処理が実行される前にMiddlewareの処理を実行する
    const store = createStore(reducers, {}, applyMiddleware(ReduxThunk, logger));
    return (
      <Provider store={store}>
        <Router>
          <Scene key="root">
            <Scene key="auth">
              <Scene key="TutorialContents" initial={true} component={TutorialContents} hideNavBar={true} />
              <Scene key="AuthContents" component={AuthContents} hideNavBar={true} />
            </Scene>
            <Scene key="main">
              <Scene key="GlobalTabContents" initial={true} component={GlobalTabContents} hideNavBar={true} />
              <Scene key="RecordRequestContents" component={RecordRequestContents} hideNavBar={true} />
              <Scene key="SettingContents" component={SettingContents} hideNavBar={true} />
              <Scene key="WebViewContents" component={WebViewContents} hideNavBar={true} />
            </Scene>
          </Scene>
        </Router>
      </Provider>
    );
  }
}

認証後に一番最初に表示されるコンテンツに関してもこのComponent状態管理は、表示したい中身のComponentやTabの切り替えとDrawer部分の開閉ハンドリングのみだったので、下記の様な構成にしています。※ Drawerの細部調整に絡む部分については省略していますが、何を行っているかはコメント内のURLを参照して頂ければと思います。

GlobalTabContents.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import {
  Drawer,
  Container
} from 'native-base';

// react-native-router-fluxのインポート宣言(Actionを使用)
import { Actions } from 'react-native-router-flux';

// グローバルコンテンツ用の共通コンポーネント
import GlobalHeader from '../common/GlobalHeader';
import GlobalFooter from '../common/GlobalFooter';
import GlobalTab from '../common/GlobalTab';

// サイドメニュー用のコンポーネント
import GlobalSideMenu from './GlobalSideMenu';

// スクリーン表示用のコンポーネント
import CommonScreenContainer from '../common/CommonScreenContainer';
import FeedScreen from './screen/feed/FeedScreen';
import RecordListScreen from './screen/recordList/RecordListScreen';
import NewsScreen from './screen/news/NewsScreen';
import QuestionScreen from './screen/question/QuestionScreen';

// タブ表示用の要素
let screenItems = [

  // タブと連動した部分
  { screen: "feed", title: "フィード", icon: "list" },
  { screen: "diary", title: "勉強日記", icon: "clipboard" },

  // ドロワーと連動した部分
  { screen: "information", title: "新着のお知らせ" },
  { screen: "question", title: "よくある質問" },

  // グローバルメニューと連動した部分
  { screen: "setting", title: "設定" }
];

// タブまでのインデックス番号
let TAB_CONTENT_INDEX_LIMIT = 1;

export default class GlobalTabContents extends React.Component {

  // - Component Life Cycles

  // コンストラクタ
  constructor(props) {
    super(props);

    // ステートの初期化を行う(ドロワーに関する設定と現在選択されている画面に関する設定)
    this.state = { isDrawerOpen: false, isDrawerDisabled: false, selectedIndex: 0 };
  };

  // MARK: - Functions

  // タイトルを表示する
  _showTitle = (index) => {
    return screenItems[index].title;
  };

  // コンテンツを表示する
  _showContents = (index) => {
    switch (index) {
      case 1:
        return (<RecordListScreen />);
      case 2:
        return (<NewsScreen />);
      case 3:
        return (<QuestionScreen />);
      default:
        return (<FeedScreen />);
    }
  };

  // タブのボタンを表示する
  _showGlobalTabs = () => {
    return screenItems.map( (tabBarItem, index) => {
      if (index <= TAB_CONTENT_INDEX_LIMIT) {
        return (
          <GlobalTab 
            key={index}
            selected={this.state.selectedIndex === index}
            title={tabBarItem.title}
            icon={tabBarItem.icon}
            onPress={ () => this._onPressGlobalTab(index) } />
        );
      }
    });
  };

  // タブのボタンを押下した際の処理
  _onPressGlobalTab = (index) => {
    this.setState({ selectedIndex: index });
  };

  // ヘッダーのメニューボタンを押下した際の処理
  _onPressMenuButton = () => {
    this._drawer._root.open();
  };

  // ヘッダーの設定ボタンを押下した際の処理
  _onPressSettingButton = () => {
    Actions.SettingContents();
  };

  // ドロワーメニュー経由でコンテンツを更新する際の処理
  _updateContentsFromDrawer = (index) => {
    this.setState({ selectedIndex: index });
    this._drawer._root.close();
  };

  // ドロワーの開閉状態を変更する際の処理
  _updateDrawerOpenState = (result) => {
    this.setState({ isDrawerOpen: result });
  };

  // - Rendering Components

  render() {
    const { selectedIndex, isDrawerDisabled } = this.state;
    /**
     * Memo:
     * NativeBaseのDrawerは下記のライブラリを拡張して作られている
     * (各種プロパティの参考) React Native Drawer
     * https://github.com/root-two/react-native-drawer#props
     */
    return (
      <Drawer
        ref={ (ref) => { this._drawer = ref; } }
        type={"overlay"}
        content={ 
          /* GlobalSideMenuコンポーネントで_updateContentsFromDrawerを実行できるようにする */
          <GlobalSideMenu updateContentsFromDrawer={this._updateContentsFromDrawer} />
        }
        onOpen={ () => this._updateDrawerOpenState(true) }
        onClose={ () => this._updateDrawerOpenState(false) }

        ・・・(以下のDrawerの設定に関しては省略)・・・

        >
        {/* 画面に表示している内容 */}
        <Container style={styles.container}>
          <GlobalHeader 
            title={this._showTitle(selectedIndex)}
            onPressMenuButton={ () => this._onPressMenuButton() }
            onPressSettingButton={ () => this._onPressSettingButton() } />
          <CommonScreenContainer screen={this._showContents(selectedIndex)} />
          <GlobalFooter tabs={this._showGlobalTabs()} />
        </Container>
      </Drawer>
    );
  };
}

// - Component Styles

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#eeeeee'
  }
});

また、react-native-router-fluxではライブラリ内にDrawerやNavigationBarの様な機能もありますが、今回はこの部分を使わずにNativeBase側で提供されているものを利用しています(なのでhideNavBar={true}としている)。特にDrawer部分については、NativeBaseで提供されている「React Native Drawer」の方がより細部の動きや配色等の微調整ができるかと思います。

4. NativeBaseを活用したComponentの実装に関する部分

以前に書いた記事NativeBaseをはじめとするUI関連のライブラリを活用してReactNativeでUIサンプルを作成した際の詳細解説でもNativeBaseの概要やiOS/Android間におけるデザイン差異の吸収やComponent構成における基本的な実装方法について解説しました。今回もよりデータ表示やネイティブアプリに近しいUIを構成する際のポイントになりそうな部分に関して解説していこうと思います。

★4-1. NativeBaseを利用した際のComponent実装に関するポイント

ここではNativeBaseを用いた部品の実装例を紹介できればと思います。この部分で紹介するNativeBaseを利用したComponentの例につきましては、公式リファレンスの実装を元に少しスタイルを当てたり、画面の実装と組み合わせて少しだけ応用のものになります。

1. Header部分の実装例:

Header部分のデザインについては、iOS/Android間でデザインの差異を感じやすい部分の1つかと思います。コンテンツに応じたタイトル表示切り替えやDrawerの開閉操作との連動処理に関するの部分の実装例は、Icon Buttonsを参考に下記のような形にしています。

header_sample.png

状態変化のタイミングについては、GlobalTabContents.js側のthis.stateが変化したタイミングで切り替わるようにしています。

GlobalTabContents.js
・・・(省略)・・・

// グローバルコンテンツ用の共通コンポーネント
import GlobalHeader from '../common/GlobalHeader';

・・・(省略)・・・

// タブ表示用の要素
let screenItems = [

  // タブと連動した部分
  { screen: "feed", title: "フィード", icon: "list" },
  { screen: "diary", title: "勉強日記", icon: "clipboard" },

  ・・・(他にもフッターに並べたいものがあれば同様に追加していく)・・・
];

export default class GlobalTabContents extends React.Component {

  // - Component Life Cycles

  // コンストラクタ
  constructor(props) {
    super(props);

    // ステートの初期化を行う(ドロワーに関する設定と現在選択されている画面に関する設定)
    this.state = { isDrawerOpen: false, isDrawerDisabled: false, selectedIndex: 0 };
  };

  // - Functions

  // タイトルを表示する
  _showTitle = (index) => {
    return screenItems[index].title;
  };

  ・・・(省略)・・・

  // ヘッダーのメニューボタンを押下した際の処理
  _onPressMenuButton = () => {
    // TODO: メニューボタン押下時の処理を記載する
  };

  // ヘッダーの設定ボタンを押下した際の処理
  _onPressSettingButton = () => {
    // TODO: 設定ボタン押下時の処理を記載する
  };

  ・・・(省略)・・・

  // - Rendering Components

  render() {
    const { selectedIndex, isDrawerDisabled } = this.state;
    return (
      {/* タイトル表示や押下時に実行したい関数を渡す */}
      <GlobalHeader title={this._showTitle(selectedIndex)}
        onPressMenuButton={ () => this._onPressMenuButton() }
        onPressSettingButton={ () => this._onPressSettingButton() } />
    );
  };
}

ヘッダー部分に関するComponentのGlobalHeaderについては、タイトルに表示するテキストとメニューボタン押下時の実行関数・右側のボタン押下時の実行関数を受け取れる想定で下記のような形にします。

GlobalHeader.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import {
  Header,
  Left,
  Right,
  Body,
  Title,
  Button,
  Icon
} from 'native-base';

export default class GlobalHeader extends React.Component {

  // - Rendering Components

  render() {
    const { title, onPressMenuButton, onPressSettingButton } = this.props;
    return (
      <Header iosBarStyle="light-content" style={styles.headerBackgroundColor} hasTabs>
        <Left>
          <Button transparent onPress={onPressMenuButton}>
            <Icon style={styles.buttonColor} name="menu" />
          </Button>
        </Left>
        <Body>
          <Title style={styles.titleColor}>{title}</Title>
        </Body>
        <Right>
          <Button transparent onPress={onPressSettingButton}>
            <Icon style={styles.buttonColor} name="settings" />
          </Button>
        </Right>
      </Header>
    );
  };
}

// - Component Styles

const styles = StyleSheet.create({
  headerBackgroundColor: {
    backgroundColor: '#4169e1'
  },
  titleColor: {
    color: '#ffffff'
  },
  buttonColor: {
    color: '#ffffff'
  }
});

Header部分は使い回す場面が多いので、共通化するための条件分岐や画面ごとの設定等でどうしても複雑になりやすい部分になるので、「どこまでの粒度で共通化するか」には十分に配慮が必要になると思います。

2. Footerのタブ部分の実装例:

アプリの表示切り替え用のTabBar実装に関しては、Footer with icons and textを元に実装を行い、どのコンテンツを表示しているかに応じてボタンの配色を切り替えたかったので下記のような形でそれぞれのComponentに切り出して実装しました。
※ このComponentではiPhoneXのSafeAreaの考慮等はすでにされています。

footer_sample.png

こちらもGlobalTabContents.js側のthis.stateが変化したタイミングで切り替わるようにしています。

GlobalTabContents.js
・・・(省略)・・・

import GlobalFooter from '../common/GlobalFooter';
import GlobalTab from '../common/GlobalTab';

・・・(省略)・・・

// タブ表示用の要素
let screenItems = [

  // タブと連動した部分
  { screen: "feed", title: "フィード", icon: "list" },
  { screen: "diary", title: "勉強日記", icon: "clipboard" },

  ・・・(他にもフッターに並べたいものがあれば同様に追加していく)・・・
];

export default class GlobalTabContents extends React.Component {

  // - Component Life Cycles

  // コンストラクタ
  constructor(props) {
    super(props);

    // ステートの初期化を行う(ドロワーに関する設定と現在選択されている画面に関する設定)
    this.state = { isDrawerOpen: false, isDrawerDisabled: false, selectedIndex: 0 };
  };

  // - Functions

  ・・・(省略)・・・

  // タブのボタンを表示する
  _showGlobalTabs = () => {
    return screenItems.map( (tabBarItem, index) => {
      return (
        <GlobalTab 
          key={index}
          selected={this.state.selectedIndex === index}
          title={tabBarItem.title}
          icon={tabBarItem.icon}
          onPress={ () => this._onPressGlobalTab(index) } />
      );
    });
  };

  // タブのボタンを押下した際の処理
  _onPressGlobalTab = (index) => {
    this.setState({ selectedIndex: index });
  };

  ・・・(省略)・・・

  // - Rendering Components

  render() {
    const { selectedIndex, isDrawerDisabled } = this.state;
    return (
      {/* 必要なボタン要素を配置する関数を渡す */}
      <GlobalFooter tabs={this._showGlobalTabs()} />
    );
  };
}

タブのボタン部分に関するComponentのGlobalTabについては、設定するアイコン名(NativeBaseに入っているreact-native-vector-iconsを参照)とアイコン下に表示するメッセージ・選択状態を受け取れる想定で下記のような形にします。

GlobalTab.js
import React, { Component } from 'react';

import {
  Platform,
  StyleSheet
} from 'react-native';

import { 
  Button,
  Icon,
  Text
} from 'native-base';

export default class GlobalTab extends React.Component {

  // - Rendering Components

  render() {
    const { selected, icon, title } = this.props;
    let deviceStyle = (Platform.OS === "ios") ? iosStyles : androidStyles;
    return (
      <Button vertical onPress={this.props.onPress}>
        <Icon style={selected ? deviceStyle.tabSelectedColor : deviceStyle.tabDefaultColor} name={icon} />
        <Text style={selected ? deviceStyle.tabSelectedColor : deviceStyle.tabDefaultColor}>{title}</Text>
      </Button>
    );
  };
}

// - Component Styles

const iosStyles = StyleSheet.create({
  tabDefaultColor: {
    color: '#999999',
  },
  tabSelectedColor: {
    color: '#4169e1'
  }
});

const androidStyles = StyleSheet.create({
  tabDefaultColor: {
    color: '#c6c6c6',
  },
  tabSelectedColor: {
    color: '#ffffff'
  }
});

GlobalFooter用のComponentでは、中に表示したいボタン用のComponentを受け取って表示するだけの形にしておきます。

GlobalFooter.js
import React, { Component } from 'react';

import { 
  Footer,
  FooterTab
} from 'native-base';

export default class GlobalFooter extends React.Component {

  // - Rendering Components

  render() {
    const { tabs } = this.props;
    return (
      <Footer>
        <FooterTab>
          {tabs}
        </FooterTab>
      </Footer>
    );
  };
}

フッター部分のComponentではFooter with badgeのように、通知があった場合のバッジ表記と組み合わせたりもできます。

3. List部分の実装例:

データと紐づく形のList実装に関しては、Dynamic Listを元に実装を行い、表示したい配列データからList構造の画面を実装していく実装例になります。dataArrayの部分に表示したいデータの配列をセットして、renderRowの部分で受け取ったデータを表示するためのComponentと紐づけるような実装になります。<List>の中には<ListItem>が入る階層構造になります。

list_sample.png

※ サンプル内ではこの形の実装を使わない部分も多々あるので、ご注意ください。

SettingList.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import {
  Content,
  List
} from 'native-base';

// 設定コンテンツ用の共通コンポーネント
import SettingListItem from './SettingListItem';

export default class SettingList extends React.Component {

  // - Rendering Components

  render() {
    const { settingItems } = this.props;
    return (
      <Content style={styles.container}>
        {/*
          // 設定コンテンツのリストの形式はこんな感じになっている
          let settingItems = {
            "items": [
              { title: "Twitter", onPressListItem: () => _goTwitterPage() },
              { title: "Facebook", onPressListItem: () => _goFacebookPage() },
              { title: "Github", onPressListItem: () => _goGithubPage() },
              { title: "Slideshare", onPressListItem: () => _goSlidesharePage() },
              { title: "Qiita", onPressListItem: () => _goQiitaPage()) },
            ]
          };
        */}
        <List dataArray={settingItems.items} renderRow={ (item) =>
          <SettingListItem title={item.title} onPressListItem={item.onPressListItem} />
        } />
      </Content>
    );
  };
}

// - Component Styles

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

データ表示用のSettingListItemでは、受け取った情報(タイトルとクリック時の実行関数)を反映して<ListItem>タグ1つ分(表示用セルになる部分)のComponentを作成しています。

SettingListItem.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import {
  Text,
  Icon,
  ListItem
} from 'native-base';

export default class SettingListItem extends React.Component {

  // - Functions

  // それぞれのソーシャルリンクに合わせた色を設定する
  _getSocialMediaColorCode = (title) => {
    switch (title) {
      case "Twitter":
        return colorStyles.twitterColor;
      case "Facebook":
        return colorStyles.facebookColor;
      case "Github":
        return colorStyles.githubColor;
      case "Slideshare":
        return colorStyles.slideshareColor;
      case "Qiita":
        return colorStyles.qiitaColor;
      default:
        return colorStyles.defaultColor;
    }
  };

  // - Rendering Components

  render() {
    const { title, onPressListItem } = this.props;
    return (
      <ListItem style={styles.listItem} onPress={onPressListItem}>
        <Icon style={[styles.listItemIcon, this._getSocialMediaColorCode(title)]} name="paper-plane" />
        <Text style={styles.listItemText}>{title}</Text>
      </ListItem>
    );
  };
}

// MARK: - Component Styles

const styles = StyleSheet.create({
  listItem: {
    height: 50
  },
  listItemIcon: {
    fontSize: 16,
  },
  listItemText: {
    fontSize: 13,
    paddingLeft: 8,
    fontWeight: 'normal',
    color: '#555555'
  }
});

const colorStyles = StyleSheet.create({
  twitterColor: {
    color: '#00aced',
  },
  facebookColor: {
    color: '#3b5998',
  },
  githubColor: {
    color: '#333333',
  },
  slideshareColor: {
    color: '#0077b5',
  },
  qiitaColor: {
    color: '#55c500',
  },
  defaultColor: {
    color: '#888888',
  }
});

NativeBaseのListタグに関しては、ReactNativeのサンプルで良く見るようなListViewの実装と若干勝手が異なる形になるので、その点は注意が必要になるかと個人的には思います。またListViewの実装例に関してはドキュメントに掲載されているサンプルコード等を見るとある程度の両方のOS対応スタイルは考慮されているので、確認しておくと良いかもしれません。

4. Input部分の実装例:

ユーザーが情報を入力する画面に関しては、Fixed Labelを元に実装を行い、認証情報やメモなどの入力項目があるフォーム実装例になります。一番上に<Form>タグがあり、中に<Item>タグで囲まれたラベルやテキスト等を入力るための欄がある構成になっています。

input_sample.png

まずは、フォームのいちばん大もとになる部分の実装についてです。<Form>タグの中にそれぞれの部品となるComponentを配置していきます。

AuthForm.js
import React, { Component } from 'react';

import { 
  Form
} from 'native-base';

・・・(省略)・・・

// 認証コンテンツ用の共通コンポーネント
import AuthFormInputMail from './AuthFormInputMail';
import AuthFormInputPassword from './AuthFormInputPassword';
import AuthFormErrorMessage from './AuthFormErrorMessage';

・・・(フォーム用の部品に関するComponentを予め切り出しておきインポートする)・・・

class AuthForm extends React.Component {

  // - Functions

  // メールアドレスの入力値変更時の処理
  _onMailChange = (text) => {
    // TODO: 入力したメールアドレスを一時的に保存するstateの変更を行う
  };

  ・・・(フォーム用の部品の動きと連動して実行する処理があれば記載する)・・・

  // - Rendering Components

  render() {
    return (
      <Form>
        <AuthFormInputMail value={this.props.mail} onChangeText={ (mail) => this._onMailChange(mail) } />
        {/*
          // TODO: このComponentのStateの変化に応じてボタンの状態やエラーメッセージの状態を反映する
        */}
      </Form>
    );
  };
}

メールアドレス入力用のフォーム要素である<AuthFormInputMail>では、受け取った情報(入力された値と値変化時の実行関数)を反映して、<Item>タグ内にある<Input>タグの振る舞いを設定します。また<Input>タグ<TextInput>を拡張して作成されているので、渡すpropsについても<TextInput>で使用しているものがそのまま利用できます。

AuthFormInputMail.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import { 
  Item,
  Input,
  Label
} from 'native-base';

export default class AuthFormInputMail extends React.Component {

  // - Rendering Components

  render() {
    const { value, onChangeText } = this.props;
    return (
      <Item stackedLabel>
        <Label style={styles.title}>メールアドレス:</Label>
        <Input
          style={styles.input}
          value={value}
          placeholder="例)username@example.com"
          onChangeText={onChangeText}
        />
      </Item>
    );
  };
}

// - Component Styles

const styles = StyleSheet.create({
  title: {
    fontSize: 13,
    fontWeight: "bold",
    color: "#333333"
  },
  input: {
    fontSize: 13,
    color: "#666666"
  }
});

入力フォームを作成するための例に関してもドキュメントに掲載されているサンプルコード等を見ると、用途に応じたデザイン例が掲載されていたりキーボードが現れた際の考慮をよしなにしてくれたりもします。

(他にもまだまだありますが今回はここまで...)

ある程度のデザインを担保してくれている反面NativeBaseのタグの構造が独特な部分があり、構造が正しくないと意図したデザインにならず崩れてしまう等独特の癖があるため、思わぬところでハマってしまうかもしれません。特にシンプルな構造のうちは良いですが、実装がシビアなUIで微調整が要するものに対しては、難しさを感じる時もありました。

★4-2. 独自のデザインが必要な場合やその他のUIライブラリと組み合わせて使用する際のポイント

NativeBaseに触れていく際には、

  1. NativeBaseのドキュメントで概要と簡単な記載方法をつかむ
  2. NativeBaseのリポジトリ内で詳細なComponent構成やスタイルを確認する

という形で調べるとより実装がしやすくなると個人的に感じているところではあります。

nativebase_reference.png

NativeBaseのドキュメントには提供しているタグの種類と一緒にiOS/Androidで表示した場合の動き方を一緒に見ることができ、簡単なサンプルコードも記載されているので、重宝するかと思いますが、実際に実装する際には 「使いたいNativeBaseで提供されているComponentがReactNative純正のどのComponentを利用しているか」 を知ることが重要になってくると思います。

ざっくりと例を挙げてみると、

  • NativeBase: <Spinner> → ReactNative: <ActivityIndicator>を拡張したもの
  • NativeBase: <Textarea> → ReactNative: <TextInput>を拡張したもの
  • NativeBase: <Button> → ReactNative: <TouchableOpacity>&<TouchableNativeFeedback>を拡張したもの
  • NativeBase: <Checkbox> → ReactNative: <TouchableOpacity>&<Platform>を拡張したもの

という様な感じになっています。

異なるOS間におけるデザイン差異の調整という点で今回はNativeBaseを選択してみましたが、この部分についてはより実装のしやすいその他のUIコンポーネントライブラリを活用する形でも良いかもしれません。

NativeBaseを利用した場合は、UI全体がNativeBaseの構造に則ることになってしまうので、独自のデザインが必要になる場合はNativeBaseの構成やスタイルと相性が悪いケースが出てきたり、実現したいComponentの構造によってはコードが煩雑になりやすいので、その点は注意した上でのUI構築が必要になります。

またサードパーティ製のUIライブラリを利用する際においても、react-native-app-intro-sliderや以前に利用したことがあるreact-native-snap-carouselの様にNativeBaseと比較的合わせやすいものもあれば、相性の全然良く無いものもあったりするので、導入する前にはライブラリとの相性が良いかという部分についても事前調査をしておく必要があるかなと個人的には感じています。

5. Reduxを伴う実装とComponentとの連携する部分に関する理解を深める

ReactNativeを始めた際に、理解するまでに時間を要したものの1つにReduxがありました。MVC/MVVMパターンでの実装はネイティブアプリ開発やRuby on RailsやCakePHP等のフレームワークに馴染みがあったもののReduxが初めてだったこともあり、まずは概念の理解等も兼ねる意味を込めてReactNativeのサンプルと一緒にSwiftのReduxライブラリを試しながら取り組みました。

reswift_sample.png

ComponentとReduxで管理している状態の紐付けに関する部分の流れがどのような形になっているかをまとめました。

★5-1. Reduxの処理の流れとComponentと合わせるところまでを理解する

このサンプルにおいては、基本的には下記のような形でReduxに関する部分をまとめています。(Action/ActionCreator/Reducerのみの構成)

directory_sample.png

冒頭に掲載した資料の中では、認証を行う部分に関するReduxの処理の流れについて掲載していますが、この記事でサンプルでReduxと紐づく処理の中でも一番シンプルな部分についての実装を見てみます。

例. Firebaseから取得したデータを一覧に表示する場合:

まずは画面から必要そうなActionを考え出してみることからはじめてみます。ニュースの一覧を取得する(引っ張って更新は考慮しない)だけの画面の場合は下記のように、

  1. 新着お知らせの取得中の状態をStateへ反映
  2. 新着お知らせの取得成功時の状態をStateへ反映
  3. 新着お知らせの取得失敗時の状態をStateへ反映

という感じでStateの更新に必要なActionを決定します。

redux/actions/types.js
・・・(その他の発行したいアクションについても同様に定義をしています)・・・

// 新着お知らせの取得&表示処理に関するActionの定義
export const NEWS_FETCH         = 'news_fetch';
export const NEWS_FETCH_SUCCESS = 'news_fetch_success';
export const NEWS_FETCH_FAILURE = 'news_fetch_failure';

次にComponenth側で上記で定義したアクションを発行するためのActionCreatorを作成します。取得する際にFirebaseに格納されている該当のデータを取得しに行くだけの処理の結果に応じて、発行するActionが違う点と成功時はdispatch({ type: NEWS_FETCH_SUCCESS, payload: values });でfirebaseから取得できたJSONをReducerに向けて送る形になります。

また、関連づけたComponentにて実行するのは、getAllNews()になります。

redux/actions/NewsActions.js
// firebaseライブラリのインポート宣言
import firebase from 'firebase';

// 最新のお知らせ情報取得関連のアクションタイプ定義のインポート宣言
import { 
    NEWS_FETCH,
    NEWS_FETCH_SUCCESS,
    NEWS_FETCH_FAILURE
} from './types';

// MARK: - Functions

// 新着のお知らせ情報取得開始時に実行されるメソッド
export const getAllNews = () => {
  return (dispatch) => {

    // 新着のお知らせ取得処理開始時のアクションを実行する
    dispatch({ type: NEWS_FETCH });

    // Firebaseの該当するDBの位置に存在するデータの一覧をJSON形式で取得する
    firebase.database().ref(`/database/news`)
      .once('value', snapshot => {
        fetchNewsSuccess(dispatch, snapshot.toJSON())
      })
      .catch(() => fetchNewsFailure(dispatch));
  };
};

// MARK: - Internal Functions

// 新着のお知らせ情報成功時に実行されるメソッド
const fetchNewsSuccess = (dispatch, values) => {

  // 新着のお知らせ情報取得成功時のアクションを実行する
  dispatch({ type: NEWS_FETCH_SUCCESS, payload: values });
};

// 新着のお知らせ情報失敗時に実行されるメソッド
const fetchNewsFailure = (dispatch) => {

  // 新着のお知らせ情報取得失敗時のアクションを実行する
  dispatch({ type: NEWS_FETCH_FAILURE });
};

発行されたActionに応じてStateを更新する部分については下記のように、

  1. 新着お知らせの取得中 → loading: trueにする
  2. 新着お知らせの取得成功時 → loading: falseにする & newsList: action.payloadにしてActionで渡されたJSONを入れる
  3. 新着お知らせの取得失敗時 → loading: falseにする & error: '新着お知らせの取得に失敗しました。'にする

という感じの処理を行ってStateの状態を更新します。

redux/reducers/NewsActions.js
// 最新のお知らせ情報のAction定義のインポート宣言
import {
  NEWS_FETCH,
  NEWS_FETCH_SUCCESS,
  NEWS_FETCH_FAILURE 
} from '../actions/types';

// 初期状態のステート定義(オブジェクトの形にする)
const initialState = { newsList: null, error: '', loading: false };

// MARK: - Functions

// 選択されたケースを元にstateの更新を行うメソッド
export default (state = initialState, action) => {
  switch (action.type) {
    case NEWS_FETCH:
      return { ...state, ...initialState, loading: true };
    case NEWS_FETCH_SUCCESS:
      return { ...state, ...initialState, newsList: action.payload };
    case NEWS_FETCH_FAILURE:
      return { ...state, error: '新着お知らせの取得に失敗しました。', loading: false };
    default:
      return state;
  }
};

また、Reducerについては下記のような形でそれぞれのReducerをひとまとまりの状態にして管理しています。

redux/reducers/index.js
// combineReducersの処理を行うためのインポート宣言
import { combineReducers } from 'redux';

//各々の要素に関するReducerのインポート宣言
// → AuthReducer: 認証処理に関するState処理に関するReducer
// → NewsReducer: 最新のお知らせの取得処理に関するState処理に関するReducer
// → FeedReducer: フィード情報の取得処理に関するState処理に関するReducer
// → RecordReducer: 記録データ情報の取得処理に関するState処理に関するReducer
// → RecordFormReducer: 記録データのフォーム処理に関するState処理に関するReducer
import AuthReducer from './AuthReducer';
import NewsReducer from './NewsReducer';
import FeedReducer from './FeedReducer';
import RecordReducer from './RecordReducer';
import RecordFormReducer from './RecordFormReducer';

// それぞれのReducerを集約して一つのStateとして管理する
export default combineReducers({
  auth: AuthReducer,
  feed: FeedReducer,
  news: NewsReducer,
  records: RecordReducer,
  recordForm: RecordFormReducer
});

最後にComponentの紐付けに関する部分について見ていきます。初期化または更新されたStateやActionCreatorとComponentを紐付ける(Stateから受け取った値をthis.props.newsListや、ActionCreatorで定義したメソッドをthis.props.getAllNews();で実行できるようにする)際には下記のような形にします。
NewsList.js内で使用しているそれぞれのComponentの具体的な部分については割愛しています。

NewsList.js
import React, { Component } from 'react';

import {
  StyleSheet
} from 'react-native';

import {
  Content,
  List
} from 'native-base';

// connectのインポート宣言を行う
import { connect } from 'react-redux';

// ActionCreator(Actionの寄せ集め)のインポート宣言(this.props.この中に定義したメソッド名の形で実行)
import {
  getAllNews
} from '../../../../../redux/actions';

// 新着のお知らせコンテンツ用の共通コンポーネント
import NewsListItem from './NewsListItem';
import NewsListLoading from './NewsListLoading';
import NewsListError from './NewsListError';

class NewsList extends React.Component {

  // - Component Life Cycles

  // コンポーネントの内容がMountされる前に行う処理
  componentWillMount() {
    this.props.getAllNews();
  }

  // - Functions

  // 再度取得する処理
  _onPressRetryButton = () => {
    this.props.getAllNews();
  };

  // MARK: - Rendering Components

  render() {
    const { newsList, error, loading } = this.props;

    // 最新のお知らせが取得中の場合における表示
    if (loading) {
      return (
        <NewsListLoading />
      );
    }

    // 最新のお知らせが取得失敗の場合における表示
    if (error !== '') {
      return (
        <NewsListError 
          error={error}
          onPressRetryButton={ () => this._onPressRetryButton() } />
      );
    }

    // 最新のお知らせ情報を配列に格納し直す
    var newsItems = [];
    for (let index in newsList) {
      newsItems.push(newsList[index]);
    }

    // 最新のお知らせが取得成功の場合における表示
    return (
      <Content style={styles.container}>
        <List dataArray={newsItems} renderRow={ (item) =>
          <NewsListItem item={item} />
        } />
      </Content>
    );
  };
}

// - Component Styles

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#ffffff'
  }
});

// newのStateから値を取得してthis.propsにセットする ※取得したいStateについては上記のReducerを参照
const mapStateToProps = ({ news }) => {

  // 引数で受け取った認証データを変数に分解する
  const { newsList, error, loading } = news;

  // 分解したそれぞれの値をオブジェクトにして返却する
  return { newsList, error, loading };
};

// インポート可能にする宣言
export default connect(mapStateToProps, { getAllNews })(NewsList);

下記のメモは、Componentとの紐付ける際の役割と何をするためのものかをざっくりとまとめたものになりますので参考になれば幸いです。

component_usage.png¥

この記事の冒頭で紹介しているサンプルではその他にも、ユーザーが入力するデータの追加・変更・削除に関する処理のものもあるので、その部分も余力があれば確認してみてください。

★5-2. 補足としてさらに見通しが良くなる構成にするために

Reduxを伴う実装については、Componentとの連携部分がどう結びついて、どのようにViewを振るうようにするかという部分がポイントになるのではないかと思います。
規模や画面数が少ない今回の様なサンプルでは、Reduxの処理とComponentを直接結びつけた(かなり強引な)書き方になっていますが、もっと規模感が大きいものであれば、mapStateToPropsmapDispatchToPropsも分割してしまった上で管理する方がより見通しが良くなると思います。

あとがき

本業とはまた違うReactNativeに触れていく中で、バージョンアップを重ねていくごとに機能追加や改善がなされておりそのスピード感に驚きながらも、気になったトピックスをかいつまみながら追いかけたり、実装のサンプルを作りながら色々試す形で現状はいます。

下記は1年前にまとめた資料になりますが、以前にSwiftとReactNativeのUI実装における違いや、最初に押さえておくべき部分に関しての考察や普段はネイティブアプリをSwiftで開発している私が実際に触れてみて気をつけておくと良さそうと思ったポイントを簡単ではありますがまとめています。

UIの実装という観点から個人的に見比べを行ってみて感じた所感としては、UIに対する考え方の相違はあるれども、設計によってはネイティブアプリに近しいUIを構築することは十分に可能ではないか とも感じています。

また個人的にSwiftとReactNativeでの実装の相違点あたっては、下記の3点の違いを押さえておくとよりそれぞれの違いを認識した上で理解を深めて行くことができるのではないかと感じております。

  1. iOSのネイティブアプリでは機能の表示や画面間での処理を行うViewControllerクラスが存在するが、ReactNativeではControllerに相当するものがない。またView(ViewController)のライフサイクルも異なっている。
  2. 画面遷移の実装方法はReactNativeとiOSのネイティブアプリでは大きく異なっている。画面遷移に関する処理に関してはReactNativeはReduxと組み合わせた実装やRNRF等用いて実現する場合が多い。一方iOSのネイティブアプリでは画面遷移に関するものはUIKitで準備されている。
  3. ReactNativeにおいて画面全体もしくは特定のComponentの状態管理については、ComponentのState(状態)の変更を受け取って状態の更新を反映させるような形の実装を行う。(iOSのネイティブアプリにおいても同様な機構を実装して行うことも可能)

iOSのネイティブアプリ開発でも、ReSwiftVueFlux など、様々なSwift製のライブラリにおいてもReduxやFluxの考え方や概念を取り入れたものが数多く存在します。iOSのネイティブアプリ開発においてもViewの変化や振る舞いが複雑化するような構成の場合は効果があるのではないかと感じています。

ReactNativeを始めた際になかなか理解するのに苦戦してしまいがちな、ReduxやReduxの概念の理解ではありますが「まずはいつも使い慣れているものからやってみる」というところからやってみる(具体的には、ライブラリのコードやサンプルを見たりMedium等で関連記事を読んでみるなど...)ことで、少しずつ理解ができるようになりました。
下記の記事やスライドは英語で書かれていますが、SwiftでRedux/Fluxを用いた実装をする際には非常に参考になりました。

ReactNativeに触れていく過程の中で、ComponentにView要素を切り出してModule(部品)として管理する思想やRedux/Fluxに関する概念や知見に関しては部分的に活かすことができたり、またクロスプラットフォーム故の両OS間のデザインに関する違いを知ることができたのは個人的に大きな収穫でもあったと思います。

そして、今回の記事の内容が少しでも実装の際にお役に立つことができればと思いますm(_ _)m