見出し画像

React NativeとReact Native for Webで3プラットフォーム対応アプリをリリースした(ほぼwrite once!)

※ 記事の賞味期限が切れたため、全体を無料公開しました。

React Native(RN)は便利です。
しかし、そのアプローチは「learn once, write anywhere.」であり、1つのソースで両OSに対応できる魔法のツールではありません。

僕は仕事で2つのRN製アプリに関わっており、どちらも大規模なものでは無いですが、やはりプラットフォームごとに処理を分けざるを得ない部分があります。

とはいえ「1つのソースでどこまでいけるか」という野望を捨てきれないのは事実。そして、あわよくばWebブラウザでも動いてほしい。

というわけで、リリースしてみました。

こんな感じで、いい日? or よくない日?をスワイプで選択するだけの単純アプリです。

Web

画像1

iOS

画像2

Andoird

画像3

iOS、Androidは各ストアからダウンロード、Web版は https://gooddays.tnantoka.com/ にアクセスするだけで使えます。
無料ですので、ぜひご利用ください(広告は表示されます)

「ほぼ」write once

1つのソースでできるだけやるという実験がメインのアプリでしたが、この規模でも以下の2点は無理でした。

- 広告(AdMob or Adsense)
- Alert(いずれRN fo Webで対応するはず)

あと、スクロールのイベントでも問題があったのですが、プルリクエストが出されていたので、それをマージしたRN for Webを使うことで対応できました。

GoodDaysの作り方

ソースコードをどう公開しようかなと考えていたんですが、一度やってみたかった有料noteで技術記事を販売するやつをやってみます。

というわけで、作り方とソースコード一式は続きからご覧ください。

なお、記事内で作る「GoodDaysClone」は「GoodDays」の一部機能を省略・変更しています。

主な省略・変更点

- ヘッダーを透明にしていない
- 当日分の結果をカレンダーから操作できない
- 言語が選択ではなく自動判別

ただし、ソースコードはストアにリリースしているフルバージョンのものも添付しています。

というわけで、お金と時間に余裕がある方は以下お付き合いください。

更新履歴

[2018-10-19]
- gooddays-master.zipに含まれていた不要なコード・ファイルを削除

[2019-08-19]
- ドメイン変更
- 記事全体を無料公開

想定している環境

主なツールのバージョンは以下のとおりです。
本チュートリアルに沿って作業する場合、Nodeのバージョンはv8やv10であれば問題ないはずですが、RNやRN for Webのバージョンは合わせないとうまくいかないと思います。

- Node: v8.12.0
- Yarn: 1.10.1
- React Native: 0.55.4
- React Native for Web: 0.8.10
- React: 16.3.1

React Nativeとは

Reactの記法でiOS、Androidのアプリが作れるツールです。

import React from 'react';
import { View, Text } from 'react-native';

class App extends React.Component {
  render() {
    return (
      <View>
        <Text>Hello</Text>
      </View>
    );
  }
}

こんな感じのコンポーネントを書くとiOSではUIView・UILabel、AndroidではView・ TextViewを用いて描画されます。

最近はExpoという開発支援ツールを通して利用することも多くなっています。(独自のネイティブモジュールが使えない等の制限がある一方、開発や検証が手軽にできる)

本記事は、RN、Expoのhello world程度は触ったことがある想定で進めます。

React Native for Webとは

React Nativeと同じ名前・インタフェースで作られたWeb用コンポーネント集です。

import { View, Text } from 'react-native'

と書いてある部分をWebpackのaliasで読み替えて、

import { View, Text } from 'react-native-web'

と解釈することで、実現されています。

先程のコンポーネントは、Webブラウザではdivとテキストで構築されます。

RN for Webの導入方法

React Native for Webを使うには以下のような方法があります。

1. create-react-app

create-react-app(react-scripts)のWebpack設定にRN for Web向けのaliasが入っているので、RN for Web単体の動作をとりあえず試してみたいとき等には楽です。

2. create-react-native-app

--with-web-supportオプションを使うことでRN for Web対応のプロジェクトが作成されます。

マルチプラットフォームのアプリを作る場合、こちらの方法が便利です。アイコンフォントなどがデフォルトで考慮されているのもメリットです。

この2つの方法は、公式のガイドでスターターキットとして紹介されています。
ただ、create-react-native-appはバージョン2でExpo CLIにマージされて、このオプションはなくなってしまいました。(立てられたissueもrepoのアーカイブと共に閉じられてしまいました)

このオプションが復活しない場合、次にあげる3の方法でやっていくことになりそうです。

3. 自分でWebpackの設定を書く

自前で設定を書く硬派な方法です。といっても書く内容はほぼ決まっているので流用可能です。

この方法は、既存のRNプロジェクトへの導入手順として公式ガイドで紹介されています。
Expoを使いたくない・使えない場合もこの方法を取ることになるでしょう。

今回は、以下のようなアプリなので2を採用する予定でした。

- 新規プロジェクト
- Expoの機能で事足りる
- マルチプラットフォーム

オプションがなくなってしまったので、本来は3を採用すべきと思われますが、手順を簡単するため、create-react-native-appの古いバージョンを使うことにします。

※ 既存のRNプロジェクトにreact-scriptsを入れることも可能です。最初はその方法で進めようとしたのですが、Webpackの設定をいじりたいケースが出てきたのでやめました。Webpackに慣れている人は、RN・react-scripts・react-app-rewiredの構成でやってみるのもありかもしれません。

作るもの

作業に入っていく前に、イメージが湧くように作るものをお見せしておきます。
今回作るのは「GoodDays」をコピーした「GoodDaysClone」というアプリです。
以下のような画面構成になります。(画面はiOSのもの)

画像4

プロジェクトの作成

では作成していきましょう。
create-react-native-appを使ってプロジェクトを作ります。

$ yarn global add create-react-native-app
$ create-react-native-app --version
create-react-native-app version: 1.0.0

$ create-react-native-app GoodDaysClone --with-web-support --scripts-version 1.14.1

これでExpoのファイルと一緒にpublic/index.htmlやwebpack.config.jsなど必要なファイルが作成されました。
またApp.jsがsrc配下になっています。

この状態で各プラットフォームで実行できます。

$ cd GoodDaysClone

$ yarn web
Project is running at http://localhost:8080/

$ yarn ios
This command requires Expo CLI.
Do you want to install it globally [Y/n]? 

$ yarn android

Expo CLIをグローバルにインストールするか聞かれるのでYにしておきます。
react-native-scriptsがdeprecatedだと言われますが、ここではそのままにしておきます。
(気になる方は表示される指示の通り、expoに置き換えてください)

ただのテキストですがマルチプラットフォームで動いています。

画像5

画像6

画像7

この状態だとRN for Webが古いのでアップデートします。(古すぎるとFlatListがサポートされていないなどの弊害があります。)
また、バージョン差異によるトラブルをさけるためreact-domをReactのバージョンに合わせます。アイコンや図形に必要になるreact-artも入れておきます。

$ yarn add react-native-web@v0.8.10 react-dom@16.3.1 react-art@16.3.1

これで比較的新しいバージョンのRN for Webが使えるようになりました。

Androidの実機確認

Androidのエミュレーターだとテキストが表示されないことがたまにあります。
細かな挙動を確認したいときは実機で見るのがよいでしょう。

adbにパスを通しておけば、yarn androidを実行するだけで動くはずです。

私の環境ではは端末をよく認識しなくなるのですが、以下のコマンドで解消します。

$ adb kill-server
$ adb start-server

Redux

ステート管理にはReduxを使います。
ただし、ファイル数を少なくするため構成はDucksにします。

reduxとreact-reduxをインストールします。

$ yarn add redux react-redux

srcの下に4つのディレクトリを作っておきます。

- constants: 定数
- components: コンポーネント
- modules: Reducerやアクションをひとまとめにしたもの
- containers: コンポーネントとmodulesをつなぐ

$ mkdir src/constants src/components src/containers src/modules

modules/index.jsにダミーのreducerを書きます。

// src/modules/index.js 

import { combineReducers } from 'redux';

const dummy = (state = {}) => state;

export default combineReducers({
  dummy,
});

あとは、App.jsでreduxを使うようにすれば導入完了です。

// src/App.js 

 import React from 'react';
+import { createStore } from 'redux';
+import { Provider } from 'react-redux';
 import { StyleSheet, Text, View } from 'react-native';
 
+import reducer from './modules';
+
+const store = createStore(reducer);
+
 export default class App extends React.Component {
   render() {
     return (
-      <View style={styles.container}>
-        <Text>Open up src/App.js to start working on your app!</Text>
-        <Text>Changes you make will automatically reload.</Text>
-        <Text>Shake your phone to open the developer menu.</Text>
-      </View>
+      <Provider store={store}>
+        <View style={styles.container}>
+          <Text>Open up src/App.js to start working on your app!</Text>
+          <Text>Changes you make will automatically reload.</Text>
+          <Text>Shake your phone to open the developer menu.</Text>
+        </View>
+      </Provider>
     );
   }
 }

ESLint

関わるプロジェクトによってセミコロン有り無し派が変わったりして、結構困ります。
今回はセミコロン有りでいきますが、普通に打ち忘れます。

その辺はlintに任せたいです。

というわけで、eslint-config-universeを使います。

$ yarn add --dev eslint prettier eslint-config-universe

設定を書きます。

// .eslintrc.js

module.exports = {
  extends: ['universe/node', 'universe/web']
};

universe/nativeというRN用の設定もあるのですが、それだとWebにしかないグローバル変数などでエラーが出るのでnodeとwebの設定を使うことにします。

簡単に実行できるようにpackage.jsonにscriptを登録しておきます。

// package.json

-    "build": "NODE_ENV=production webpack -p --config ./webpack.config.js"
+    "build": "NODE_ENV=production webpack -p --config ./webpack.config.js",
+    "lint": "eslint src",
+    "fix": "eslint src --fix"

これで、

$ yarn lint

でチェックできます。また、

$ yarn fix

で自動修正できるものは修正してくれます。

gitのprecommitなどのタイミングで実行してもいいと思いますが、僕はあの挙動が苦手なのでやりません。huskyなどを使うと簡単に設定できます。

画面遷移(react-navigation)

今回のアプリは以下の3画面で構成されます。

- Today画面
- Calendar画面
- Settings画面

中身を作っていく前に画面遷移部分を作ってしまいます。
Today→Calendarはスタック、Settingsはモーダルによる遷移とします。

react-navigationを入れます。最新版だとRN for Webでエラーになったのでバージョン指定します。

$ yarn add react-navigation@2.13.0

3つの画面(Screen)を作ります。
これらはReduxとつなぐのでcontainersに入れます。

// src/containers/TodayScreen.js 

import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { View } from 'react-native';

export class TodayScreen extends React.Component {
  render() {
    return (
      <View style={{ flex: 1, backgroundColor: 'red' }}/>
    );
  }
}

TodayScreen.propTypes = {
};

const mapStateToProps = state => ({
});

const mapDispatchToProps = dispatch => ({
});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodayScreen);

名前と色が違うだけなので、CalendarとTodayは省略します。

定数としてテキストの色を定義しておきます。

// src/constants/index.js

export const textColor = 'rgba(0, 0, 0, 0.87)';

Navigatorというファイルに画面遷移を定義します。
ボタンにはFontAwesomeを使います。

// src/Navigator.js
import React from 'react';
import { createStackNavigator } from 'react-navigation';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { FontAwesome } from '@expo/vector-icons';
  
import TodayScreen from './containers/TodayScreen';
import CalendarScreen from './containers/CalendarScreen';
import SettingsScreen from './containers/SettingsScreen';
import { textColor } from './constants';

const MainStack = createStackNavigator({
  Today: {
    screen: TodayScreen,
    navigationOptions: ({ navigation }) => ({
      title: 'Today',
      headerLeft: (
        <TouchableOpacity
          onPress={() => navigation.navigate('Settings')}
          style={styles.headerButton}>
          <FontAwesome name="cog" size={24} color={textColor} />
        </TouchableOpacity>
      ),
      headerRight: (
        <TouchableOpacity
          onPress={() => navigation.navigate('Calendar')}
          style={styles.headerButton}>
          <FontAwesome name="calendar-o" size={24} color={textColor} />
        </TouchableOpacity>
      ),
    }),
  },
  Calendar: {
    screen: CalendarScreen,
    navigationOptions: ({ navigation }) => ({
      title: 'Calendar',
      headerLeft: (
        <TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerButton}>
          <FontAwesome name="clock-o" size={24} color={textColor} />
        </TouchableOpacity>
      ),
    }),
  },
});

const SettingsStack = createStackNavigator({
  Settings: {
    screen: SettingsScreen,
    navigationOptions: ({ navigation }) => ({
      title: 'Settings',
      headerLeft: (
        <TouchableOpacity
          onPress={() => {
            navigation.popToTop();
            navigation.goBack();
          }}
          style={styles.headerButton}>
          <FontAwesome name="close" size={24} color={textColor} />
        </TouchableOpacity>
      ),
    }),
  },
});

export default createStackNavigator(
  { 
    Main: {
      screen: MainStack,
    },
    Settings: {
      screen: SettingsStack,
    },
  },
  { 
    mode: 'modal',
    headerMode: 'none',
  }
);

const styles = StyleSheet.create({
  headerButton: {
    marginHorizontal: 10,
  },
});

App.jsでデフォルトのテキストの代わりにNavigatorを使うように変更します。

// src/App.js 

 import React from 'react';
 import { createStore } from 'redux';
 import { Provider } from 'react-redux';
-import { StyleSheet, Text, View } from 'react-native';
 
 import reducer from './modules';
+import Navigator from './Navigator';
 
 const store = createStore(reducer);
// src/App.js 

   render() {
     return (
       <Provider store={store}>
-        <View style={styles.container}>
-          <Text>Open up src/App.js to start working on your app!</Text>
-          <Text>Changes you make will automatically reload.</Text>
-          <Text>Shake your phone to open the developer menu.</Text>
-        </View>
+        <Navigator />
       </Provider>
     );
   }
 }
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    backgroundColor: '#fff',
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-});

これでネイティブアプリは動きますが、残念ながらyarn webを実行するとエラーになります。

$ yarn web
ERROR in ./node_modules/react-navigation-stack/dist/views/Header/Header.js
Module not found: Error: Can't resolve 'react-native-web/dist/exports/MaskedViewIOS' in '/path/to/GoodDaysClone/node_modules/react-navigation-stack/dist/views/Header'
 @ ./node_modules/react-navigation-stack/dist/views/Header/Header.js 1:1698-1752

Webpackの設定を少し修正します。

// webpack.config.js 

       'react-native': 'react-native-web',
+      'react-native-web/dist/exports/MaskedViewIOS': 'react-native-web/dist/index.js',

これで画面遷移できるようになりました。

画像8

画像9

画像10

今回はRouterとReduxを連携させませんが、Redux連携は公式サポートされなくなるようなので、連携したい場合は他のRouterを使う方がよいかもしれません。

remに注意
スタイルにremを使うと、Webでは問題なく動くように見えますが、ネイティブでエラーになるのでご注意を。

Today画面

アプリのメインとなる「いい日?よくない日?」選択機能を持つ画面です。
自前でジェスチャーを操作した方が細かなカスタマイズができますが、マルチプラットフォームでそれをやるのは骨が折れそうなのでScrollViewで実装します。

定数として色とフラグを定義します。

// src/constants/index.js

 export const textColor = 'rgba(0, 0, 0, 0.87)';
+export const goodColor = '#8bc34a';
+export const badColor = '#ff5252';
+
+export const goodFlag = 1;
+export const badFlag = 0;
+export const defaultFlag = -1;

modulesにToday用のReducerやアクションを用意します。

// src/modules/today.js 

import { defaultFlag } from '../constants';

const SET_IS_GOOD = 'goodays/today/SET_IS_GOOD';
const SET_IS_SCROLLING = 'goodays/today/SET_IS_SCROLLING';

const initialState = {
  isGood: defaultFlag,
  isScrolling: false,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case SET_IS_GOOD:
      return {
        ...state,
        isGood: action.isGood,
      };
    case SET_IS_SCROLLING:
      return {
        ...state,
        isScrolling: action.isScrolling,
      };
    default:
      return state;
  }
}

export function setIsGood(isGood) {
  return {
    type: SET_IS_GOOD,
    isGood,
  };
}

export function setIsScrolling(isScrolling) {
  return {
    type: SET_IS_SCROLLING,
    isScrolling,
  };
}

isGood(いい日?)とisScrolling(スクロール中?)という2つのフラグだけを持つシンプルなモジュールです。

ダミーのReducerは用済みです。

// src/modules/index.js

 import { combineReducers } from 'redux';
 
-const dummy = (state = {}) => state;
+import today from './today';
 
 export default combineReducers({
-  dummy,
+  today,
 });

Todayコンポーネントを作ります。

// src/components/Today.js

import React from 'react';
import { Dimensions, FlatList, StyleSheet, View, Text } from 'react-native';
import PropTypes from 'prop-types';
  
import { textColor, goodColor, badColor, goodFlag, badFlag } from '../constants';
  
const { width } = Dimensions.get('window');
  
export default class TodaySelector extends React.Component {
  componentDidMount() {
    setTimeout(() => {
      this.scrollToOffset(false);
    }, 100);
  }
  
  componentDidUpdate() {
    const { isScrolling } = this.props;
    if (isScrolling) {
      return;
    }
    this.scrollToOffset(true);
  }
  
  scrollToOffset = animated => {
    const { isGood } = this.props;
    let offset = width * 0.5;
    switch (isGood) {
      case goodFlag:
        offset = 0;
        break;
      case badFlag:
        offset = width;
        break;
    }
    this._flatList.scrollToOffset({
      offset,
      animated,
    });
  };

  render() {
    const { onScrollEndDrag, onScroll } = this.props;
    return (
      <FlatList
        horizontal
        bounces={false}
        data={[{ key: goodColor }, { key: badColor }]}
        renderItem={({ item, index }) => (
          <View style={[styles.page, { backgroundColor: item.key }]} key={item.key}>
            <Text style={styles.pageText}>{index === 0 ? 'GOOD' : 'BAD'}</Text>
          </View>
        )}
        ref={component => (this._flatList = component)}
        onScrollEndDrag={onScrollEndDrag}
        onScroll={onScroll}
      />
    );
  }
}

const styles = StyleSheet.create({
  page: {
    width,
    justifyContent: 'center',
    alignItems: 'center',
  },
  pageText: {
    fontSize: 96,
    color: textColor,
  },
});

TodaySelector.propTypes = {
  isGood: PropTypes.number.isRequired,
  isScrolling: PropTypes.bool.isRequired,
  onScrollEndDrag: PropTypes.func.isRequired,
  onScroll: PropTypes.func.isRequired,
};

GoodかBadか未選択かによってスクロールの位置が変わるようにしています。
それによって中途半端な位置で止めた場合は元に戻ります。

これをTodayScreenから呼び出します。

// src/containers/TodayScreen.js

 import React from 'react';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
-import { View } from 'react-native';
+import { Dimensions } from 'react-native';
+
+import Today from '../components/Today';
+import { goodFlag, badFlag, defaultFlag } from '../constants';
+import { setIsGood, setIsScrolling } from '../modules/today';
+
+const { width } = Dimensions.get('window');
 
 export class TodayScreen extends React.Component {
+  onScroll = () => {
+    const { setIsScrolling } = this.props;
+    setIsScrolling(true);
+  };
+
+  onScrollEndDrag = event => {
+    const { setIsGood, setIsScrolling } = this.props;
+    const offsetX = event.nativeEvent.contentOffset.x;
+    let isGood = defaultFlag;
+    if (offsetX < width * 0.15) {
+      isGood = goodFlag;
+    } else if (offsetX > width * 0.85) {
+      isGood = badFlag;
+    }
+    setIsGood(isGood);
+    setIsScrolling(false);
+  };
+
   render() {
+    const { isGood, isScrolling } = this.props;
     return (
-      <View style={{ flex: 1, backgroundColor: 'red' }}/>
+      <Today
+        onScrollEndDrag={this.onScrollEndDrag}
+        onScroll={this.onScroll}
+        isGood={isGood}
+        isScrolling={isScrolling}
+      />
     );
   }
 }
 
 TodayScreen.propTypes = {
+  isGood: PropTypes.number.isRequired,
+  isScrolling: PropTypes.bool.isRequired,
+  setIsGood: PropTypes.func.isRequired,
+  setIsScrolling: PropTypes.func.isRequired,
 };
 
 const mapStateToProps = state => ({
+  isGood: state.today.isGood,
+  isScrolling: state.today.isScrolling,
 });
 
 const mapDispatchToProps = dispatch => ({
+  setIsGood: isGood => dispatch(setIsGood(isGood)),
+  setIsScrolling: isScrolling => dispatch(setIsScrolling(isScrolling)),
 });
 
 export default connect(

onScrollEndDragでは、一定以上スクロールされていたらGoodかBad、そうじゃなければ未選択に戻すようにしています。

さて、これでiOS・Androidではうごくのですが、WebブラウザでScroll終了(onScrollEndDrag)が検知されません。
そのため、スクロールが中途半端な位置で止まってしまいます。

画像11

幸い、この問題の解消を試みたプルリクエストがありました。これを取り込んだRN for Webを使うようにします。

最新のmasterをチェックアウトするとバージョンが変わってしまうのでこのコミットからにします。(0.8.10にPickerのバグ修正が加わった状態)

GoodDaysCloneのディレクトリの外で作業します。

$ git clone git@github.com:necolas/react-native-web.git
$ cd react-native-web
$ git checkout -b fix-scroll ef97adec6e7202f318d8ba7d4e43d47e2618b543
$ git pull https://github.com/gwuhaolin/react-native-web.git master

これでプルリクエストを取り込めたので、packします。

$ yarn install
$ yarn prerelease
$ cd packages/react-native-web
$ yarn pack

生成された「react-native-web-v0.8.10.tgz」をGoodDaysCloneのディレクトリに移動して、package.jsonでそのtgzファイルを使うようにします。

// package.json

-    "react-native-web": "0.8.10",
+    "react-native-web": "file:./react-native-web-v0.8.10.tgz",

yarn installすればWebでもスクロール終了が検知されるようになります。

画像12

なお、Firefoxで正常に動かないようですが、仕方無しとします。
(僕はFirefoxユーザーなのですが…)

Calendar

今度はカレンダー画面です。
各日付の状態を見れ、タップでGood・Bad・未選択を変更できるようにします。

日付の操作を簡単にするため、date-fnsを入れます

$ yarn add date-fns

moduleを用意します。

// src/modules/calendar.js

import addMonths from 'date-fns/add_months';
import startOfMonth from 'date-fns/start_of_month';
import startOfDay from 'date-fns/start_of_day';
import endOfMonth from 'date-fns/end_of_month';
import eachDay from 'date-fns/each_day';
  
import { defaultFlag } from '../constants';
  
const UPDATE_DATE = 'goodays/calendar/UPDATE_DATE';
const SET_PAGE = 'goodays/calendar/SET_PAGE';
  
const initialState = {
  days: {},
  page: 0,
  dates: [],
};
  
export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case UPDATE_DATE: {
      const days = { ...state.days };
      days[startOfDay(action.date).getTime()] = { isGood: action.isGood };
      return {
        ...state,
        days,
        dates: buildDates(new Date(state.date), days),
      };
    }
    case SET_PAGE: {
      const page = action.page;
      const date = addMonths(startOfMonth(new Date()), page).getTime();
      return {
        ...state,
        page,
        date,
        dates: buildDates(date, state.days),
      };
    }
    default:
      return state;
  }
}

export function updateDate(date, isGood) {
  return {
    type: UPDATE_DATE,
    date,
    isGood,
  };
}

export function setPage(page) {
  return {
    type: SET_PAGE,
    page,
  };
}

function buildDates(date, days) {
  return eachDay(startOfMonth(date), endOfMonth(date)).map(day => {
    const time = day.getTime();
    const selected = days[time];
    return {
      key: `${time}`,
      isGood: selected ? selected.isGood : defaultFlag,
      date: day.getDate(),
    };
  });
}

daysというオブジェクトにこれまでのisGoodの履歴を保持します。
pageを指定したらそのpageに該当する月のデータをdaysから検索して日付一覧(dates)を生成しています。

moduleをReducerに追加します。

// src/modules/index.js

 import today from './today';
+import calendar from './calendar';
 
 export default combineReducers({
   today,
+  calendar,
 });

Calendarコンポーネントを作ります。

// src/components/Calendar.js

import React from 'react';
import { Dimensions, StyleSheet, FlatList, TouchableOpacity, Text } from 'react-native';
import PropTypes from 'prop-types';
import isSameDay from 'date-fns/is_same_day';

import {
  defaultColor,
  goodColor,
  badColor,
  textColor,
  goodFlag,
  badFlag,
  borderColor,
} from '../constants';

const NUM_COLUMNS = 7;
const { width } = Dimensions.get('window');
const cellSize = width / NUM_COLUMNS;

export default class Calendar extends React.Component {
  backgroundColor(item) {
    let color;
    switch (item.isGood) {
      case goodFlag:
        color = goodColor;
        break;
      case badFlag:
        color = badColor;
        break;
      default:
        color = defaultColor;
        break;
    }
    return color;
  }

  borderWidth(item) {
    return this.isToday(item) ? 3 : 0.5;
  }

  isToday(item) {
    return isSameDay(new Date(), new Date(parseInt(item.key, 10)));
  }

  renderItem = item => {
    const { onPress } = this.props;
    return (
      <TouchableOpacity
        style={[
          styles.cell,
          { backgroundColor: this.backgroundColor(item), borderWidth: this.borderWidth(item) },
        ]}
        onPress={() => (this.isToday(item) ? null : onPress(item))}>
        <Text style={styles.cellText}>{item.date}</Text>
      </TouchableOpacity>
    );
  };

  render() {
    const { dates } = this.props;
    return (
      <FlatList
        data={dates}
        renderItem={({ item }) => this.renderItem(item)}
        numColumns={NUM_COLUMNS}
        style={styles.list}
      />
    );
  }
}

const styles = StyleSheet.create({
  list: {
    backgroundColor: '#fff',
  },
  cell: {
    width: cellSize,
    height: cellSize,
    borderColor,
    justifyContent: 'center',
    alignItems: 'center',
  },
  cellText: {
    color: textColor,
    fontSize: 16,
  },
});

Calendar.propTypes = {
  dates: PropTypes.array.isRequired,
  onPress: PropTypes.func.isRequired,
};

FlatListを使って日付を7個ずつ並べています。
TouchableOapcityを使ってタップで状態を変えられるようにしていますが、Today画面更新の処理が煩雑になるため、本日分は操作できないようにしています。

CalendarScreenから呼び出します。

// src/containers/CalendarScreen.js

 import React from 'react';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
-import { View } from 'react-native';
+
+import Calendar from '../components/Calendar';
+import { updateDate } from '../modules/calendar';
+import { goodFlag, badFlag, defaultFlag } from '../constants';
 
 export class CalendarScreen extends React.Component {
+  onPress = item => {
+    const { updateDate } = this.props;
+    let isGood;
+    switch (item.isGood) {
+      case goodFlag:
+        isGood = badFlag;
+        break;
+      case badFlag:
+        isGood = defaultFlag;
+        break;
+      default:
+        isGood = goodFlag;
+        break;
+    }
+    const date = parseInt(item.key, 10);
+    updateDate(date, isGood);
+  };
+
   render() {
-    return (
-      <View style={{ flex: 1, backgroundColor: 'green' }}/>
-    );
+    const { dates } = this.props;
+    return <Calendar dates={dates} onPress={this.onPress} />;
   }
 }
 
 CalendarScreen.propTypes = {
+  dates: PropTypes.array.isRequired,
+  updateDate: PropTypes.func.isRequired,
 };
 
 const mapStateToProps = state => ({
+  dates: state.calendar.dates,
 });
 
 const mapDispatchToProps = dispatch => ({
+  updateDate: (date, isGood) => dispatch(updateDate(date, isGood)),
 });
 
 export default connect(

セルのタップ時に呼ばれるメソッドでmoduleのudpateDateを呼びデータを更新しています。

Header関連のコンテナーを2つ作ります。
タイトルに月を反映させるためのものと、ボタンでカレンダーの月を操作するためのものです。

// src/containers/CalendarTitle.js

import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Text, StyleSheet, View } from 'react-native';

import { textColor } from '../constants';

class CalendarTitle extends React.Component {
  title = () => {
    const { date } = this.props;
    return `${date.getFullYear()}-${date.getMonth() + 1}`;
  };

  render() {
    return (
      <View style={styles.title}>
        <Text style={styles.titleText}>{this.title()}</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  title: {
    flex: 1,
  },
  titleText: {
    color: textColor,
    fontSize: 20,
    fontWeight: 'bold',
    textAlign: 'center',
  },
});

CalendarTitle.propTypes = {
  date: PropTypes.instanceOf(Date).isRequired,
};

const mapStateToProps = state => ({
  date: new Date(state.calendar.date),
});

const mapDispatchToProps = () => ({});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(CalendarTitle);

こちらはCalendarのReducerに入っているdateをそのまま表示しているだけです。

// src/containers/CalendarButtons.js

import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { TouchableOpacity, View, StyleSheet } from 'react-native';
import { FontAwesome } from '@expo/vector-icons';

import { setPage } from '../modules/calendar';
import { textColor } from '../constants';

class CalendarButtons extends React.Component {
  componentDidMount() {
    const { setPage } = this.props;
    setPage(0);
  }

  render() {
    const { page, setPage } = this.props;
    return (
      <View style={{ flexDirection: 'row' }}>
        <TouchableOpacity onPress={() => setPage(page - 1)} style={styles.headerButton}>
          <FontAwesome name="chevron-left" size={20} color={textColor} />
        </TouchableOpacity>
        <TouchableOpacity onPress={() => setPage(page + 1)} style={styles.headerButton}>
          <FontAwesome name="chevron-right" size={20} color={textColor} />
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  headerButton: {
    marginHorizontal: 10,
  },
});

CalendarButtons.propTypes = {
  page: PropTypes.number.isRequired,
  setPage: PropTypes.func.isRequired,
};

const mapStateToProps = state => ({
  page: state.calendar.page,
});

const mapDispatchToProps = dispatch => ({
  setPage: page => dispatch(setPage(page)),
});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(CalendarButtons);

こちらは、ボタンでpageを操作しています。

コンテナーをNavigatorで利用します。

// src/Navigator.js

 import { FontAwesome } from '@expo/vector-icons';
 
 import TodayScreen from './containers/TodayScreen';
+import TodayTitle from './containers/TodayTitle';
 import CalendarScreen from './containers/CalendarScreen';
+import CalendarTitle from './containers/CalendarTitle';
+import CalendarButtons from './containers/CalendarButtons';
 import SettingsScreen from './containers/SettingsScreen';
 import { textColor } from './constants';
// src/Navigator.js

   Today: {
     screen: TodayScreen,
     navigationOptions: ({ navigation }) => ({
-      title: 'Today',
+      headerTitle: TodayTitle,
       headerLeft: (
         <TouchableOpacity
           onPress={() => navigation.navigate('Settings')}
// src/Navigator.js

   Calendar: {
     screen: CalendarScreen,
     navigationOptions: ({ navigation }) => ({
-      title: 'Calendar',
+      headerTitle: CalendarTitle,
       headerLeft: (
         <TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerButton}>
           <FontAwesome name="clock-o" size={24} color={textColor} />
         </TouchableOpacity>
       ),
+      headerRight: <CalendarButtons />,
     }),
   },
 });

これでカレンダー画面ができました。

画像13

次に、Today画面で操作したときにもカレンダーに反映するためにupdateDateを呼びます。
(同じデータを見れば済むので、Reducerの構造を変える方がよいかもしれませんが)

todayモジュールにdateを追加します。

// src/modules/today.js

+import isSameDay from 'date-fns/is_same_day';
+
 import { defaultFlag } from '../constants';
 
+const SET_DATE = 'goodays/today/SET_DATE';
 const SET_IS_GOOD = 'goodays/today/SET_IS_GOOD';
 const SET_IS_SCROLLING = 'goodays/today/SET_IS_SCROLLING';
 
 const initialState = {
+  date: new Date().getTime(),
   isGood: defaultFlag,
   isScrolling: false,
 };
 
 export default function reducer(state = initialState, action = {}) {
   switch (action.type) {
+    case SET_DATE: {
+      const isGood = isSameDay(new Date(state.date), new Date(action.date))
+        ? state.isGood
+        : defaultFlag;
+      return {
+        ...state,
+        date: action.date,
+        isGood,
+      };
+    }
     case SET_IS_GOOD:
       return {
         ...state,
// src/modules/today.js

   }
 }
 
+export function setDate(date) {
+  return {
+    type: SET_DATE,
+    date,
+  };

このdateはCalendarTitleと同様にTodayTitleを作って表示するようにします。
中身はCalendarTitleとほぼ同じなので省略しますが、calendarではなくtodayのdateを使います。

App.jsでアプリ起動時にdateを設定します。
また、アプリのアクティブ状態が変わったときにも自動で更新されるようにします。

// src/App.js

 import React from 'react';
 import { createStore } from 'redux';
 import { Provider } from 'react-redux';
+import { AppState } from 'react-native';
 
 import reducer from './modules';
+import { setDate } from './modules/today';
 import Navigator from './Navigator';
 
 const store = createStore(reducer);
 
 export default class App extends React.Component {
+  componentDidMount() {
+    AppState.addEventListener('change', this.handleAppStateChange);
+    this.handleAppStateChange();
+  }
+
+  componentWillUnmount() {
+    AppState.removeEventListener('change', this.handleAppStateChange);
+  }
+
+  handleAppStateChange = nextAppState => {
+    store.dispatch(setDate(new Date().getTime()));
+  };
+
   render() {
     return (
       <Provider store={store}>

スクロールの終了時に判別したisGoodを、このdateを使ってupdateDateに渡してカレンダー側のデータを更新します。

// src/containers/TodayScreen.js

 import Today from '../components/Today';
 import { goodFlag, badFlag, defaultFlag } from '../constants';
 import { setIsGood, setIsScrolling } from '../modules/today';
+import { updateDate } from '../modules/calendar';
 
 const { width } = Dimensions.get('window');
// src/containers/TodayScreen.js

   };
 
   onScrollEndDrag = event => {
-    const { setIsGood, setIsScrolling } = this.props;
+    const { setIsGood, setIsScrolling, updateDate, date } = this.props;
     const offsetX = event.nativeEvent.contentOffset.x;
     let isGood = defaultFlag;
     if (offsetX < width * 0.15) {
// src/containers/TodayScreen.js

       isGood = badFlag;
     }
     setIsGood(isGood);
+    updateDate(date, isGood);
     setIsScrolling(false);
   };
// src/containers/TodayScreen.js

   isScrolling: PropTypes.bool.isRequired,
   setIsGood: PropTypes.func.isRequired,
   setIsScrolling: PropTypes.func.isRequired,
+  updateDate: PropTypes.func.isRequired,
+  date: PropTypes.instanceOf(Date).isRequired,
 };
 
 const mapStateToProps = state => ({
   isGood: state.today.isGood,
   isScrolling: state.today.isScrolling,
+  date: new Date(state.today.date),
 });

 const mapDispatchToProps = dispatch => ({
   setIsGood: isGood => dispatch(setIsGood(isGood)),
   setIsScrolling: isScrolling => dispatch(setIsScrolling(isScrolling)),
+  updateDate: (date, isGood) => dispatch(updateDate(date, isGood)),
 });
 
 export default connect(

これでToday画面からカレンダーに保存されるようになりました。(カレンダーからは操作できない30日に色が付いているのがわかります)

画像14

Settings

最後の画面は設定です。
単純なアプリなので特に設定する項目はないのですが、とりあえずデータの全削除ボタンをつけます。

//src/modules/calendar.js

 const UPDATE_DATE = 'goodays/calendar/UPDATE_DATE';
 const SET_PAGE = 'goodays/calendar/SET_PAGE';
+const RESET_DAYS = 'goodays/calendar/RESET_DAYS';
 
 const initialState = {
   days: {},
//src/modules/calendar.js

         dates: buildDates(date, state.days),
       };
     }
+    case RESET_DAYS: {
+      const days = {};
+      return {
+        ...state,
+        days,
+        dates: buildDates(state.date, days),
+      };
+    }
     default:
       return state;
   }
//src/modules/calendar.js

   };
 }
 
+export function resetDays() {
+  return {
+    type: RESET_DAYS,
+  };
+}
+

Settingsコンポーネントを作ります。

// src/components/Settings.js

import React from 'react';
import { StyleSheet, View, Button, Alert, SectionList, Text, Platform } from 'react-native';
import PropTypes from 'prop-types';

import { defaultColor, badColor } from '../constants';

export default class Settings extends React.Component {
  onReset = () => {
    const { onReset } = this.props;
    if (Platform.OS === 'web') {
      if (confirm('Are you sure?')) {
        onReset();
      }
    } else {
      Alert.alert('Are you sure?', null, [
        { text: 'Cancel', onPress: () => {}, style: 'cancel' },
        { text: 'OK', onPress: onReset },
      ]);
    }
  };

  render() {
    return (
      <View style={styles.container}>
        <SectionList
          style={styles.list}
          scrollEnabled={false}
          renderItem={({ item, index, section }) => (
            <View style={styles.cell}>
              {item === 0 && (
                <Button
                  onPress={this.onReset}
                  color={badColor}
                  title="Delete all data"
                />
              )}
            </View>
          )}
          renderSectionHeader={({ section: { title } }) => (
            <View style={styles.header}>
              <Text style={styles.headerText}>{title}</Text>
            </View>
          )}
          sections={[
            { title: 'Danger Zone', data: [0] },
          ]}
          keyExtractor={(item, index) => item + index}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: defaultColor,
    flex: 1,
  },
  header: {
    padding: 10,
  },
  headerText: {
    fontWeight: 'bold',
    fontSize: 20,
  },
  cell: {
    padding: 10,
  },
});

Settings.propTypes = {
  onReset: PropTypes.func.isRequired,
};

SectionListを使って項目が増えても対応できるようにしています。

確認のアラート部分は仕方なくプラットフォームによって処理を分岐しています。
技術的に不可能ではないはずなので、RN for Webのアップデートによっていずれ不要になると思います。

SettingsScreenから呼び出します。

// src/containers/SettingsScreen.js

 import React from 'react';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
-import { View } from 'react-native';
+
+import Settings from '../components/Settings';
+import { resetDays } from '../modules/calendar';
+import { setIsGood } from '../modules/today';
+import { defaultFlag } from '../constants';
 
 export class SettingsScreen extends React.Component {
+  onReset = () => {
+    const { resetDays, setIsGood } = this.props;
+    resetDays();
+    setIsGood(defaultFlag);
+  };
+
   render() {
     return (
-      <View style={{ flex: 1, backgroundColor: 'blue' }}/>
+      <Settings
+        onReset={this.onReset}
+      />
     );
   }
 }
 
 SettingsScreen.propTypes = {
+  resetDays: PropTypes.func.isRequired,
+  setIsGood: PropTypes.func.isRequired,
 };
 
 const mapStateToProps = state => ({
 });

 const mapDispatchToProps = dispatch => ({
+  resetDays: () => dispatch(resetDays()),
+  setIsGood: isGood => dispatch(setIsGood(isGood)),
 });
 
 export default connect(

これで全データリセットの機能ができました。

画像15

画像16

i18n

リリース版のGoodDaysのときはマルチプラットフォームに値が取るのが面倒と思い込んでいたのと、個人的に選択できるのが好きなので設定するようにしました。
expo-webを使えばWebでも同じインタフェースで言語設定を取れるようなので自動判別にしてみます。

expo-webはreact-native-webと同じ仕組みで、expoの機能のweb版を提供してくれるものです。

expo-webが古いのでupdateします。

$ yarn add expo-web@0.0.14 

言語情報は、settingsというモジュールに持たせることにします。

// src/modules/settings.js 

const SET_LOCALE = 'goodays/settings/SET_LOCALE';

const i18n = {
  en: {
    settings: 'Settings',
    areYouSure: 'Are you sure?',
    deleteAllData: 'Delete all data',
    cancel: 'Cancel',
    ok: 'OK',
    dangerZone: 'Danger Zone',
  },
  ja: {
    settings: '設定',
    areYouSure: 'よろしいですか?',
    deleteAllData: '全てのデータを削除',
    cancel: 'キャンセル',
    ok: 'はい',
    dangerZone: '危険な操作',
  },
};

const initialState = {
  locale: 'en',
  i18n: { ...i18n.en },
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case SET_LOCALE: {
      const locale = action.locale;
      return {
        ...state,
        locale,
        i18n: { ...i18n[locale] },
      };
    }
    default:
      return state;
  }
}

export function setLocale(locale) {
  return {
    type: SET_LOCALE,
    locale,
  };
}

Reducerを修正します。

// src/modules/index.js
 
 import today from './today';
 import calendar from './calendar';
+import settings from './settings';
 
 export default combineReducers({
   today,
   calendar,
+  settings,
 });

App.jsで言語を判別して設定します。

// src/App.js

 import { createStore } from 'redux';
 import { Provider } from 'react-redux';
 import { AppState } from 'react-native';
+import { DangerZone } from 'expo';
 
 import reducer from './modules';
 import { setDate } from './modules/today';
+import { setLocale } from './modules/settings';
 import Navigator from './Navigator';
 
 const store = createStore(reducer);
+const { Localization } = DangerZone;
 
 export default class App extends React.Component {
-  componentDidMount() {
+  async componentDidMount() {
     AppState.addEventListener('change', this.handleAppStateChange);
     this.handleAppStateChange();
+
+    const currentLocale = await Localization.getCurrentLocaleAsync();
+    const locale = /ja/i.test(currentLocale) ? 'ja' : 'en';
+    store.dispatch(setLocale(locale));
   }
 
   componentWillUnmount() {

Settingsコンポーネントで使います。

// src/components/Settings.js
 
 export default class Settings extends React.Component {
   onReset = () => {
-    const { onReset } = this.props;
+    const { onReset, i18n } = this.props;
     if (Platform.OS === 'web') {
-      if (confirm('Are you sure?')) {
+      if (confirm(i18n.areYouSure)) {
         onReset();
       }
     } else {
-      Alert.alert('Are you sure?', null, [
-        { text: 'Cancel', onPress: () => {}, style: 'cancel' },
-        { text: 'OK', onPress: onReset },
+      Alert.alert(i18n.areYouSure, null, [
+        { text: i18n.cancel, onPress: () => {}, style: 'cancel' },
+        { text: i18n.ok, onPress: onReset },
       ]);
     }
   };
 
   render() {
+    const { i18n } = this.props;
     return (
       <View style={styles.container}>
         <SectionList
// src/components/Settings.js
 
                 <Button
                   onPress={this.onReset}
                   color={badColor}
-                  title="Delete all data"
+                  title={i18n.deleteAllData}
                 />
               )}
             </View>

SettingsScreenからi18nを渡します。

// src/containers/SettingsScreen.js

   };
 
   render() {
+    const { i18n } = this.props;
     return (
       <Settings
         onReset={this.onReset}
+        i18n={i18n}
       />
     );
   }
// src/containers/SettingsScreen.js

 SettingsScreen.propTypes = {
   resetDays: PropTypes.func.isRequired,
   setIsGood: PropTypes.func.isRequired,
+  i18n: PropTypes.object.isRequired,
 };
 
 const mapStateToProps = state => ({
+  i18n: state.settings.i18n,
 });
 
 const mapDispatchToProps = dispatch => ({

CalendarTitle等と同様、Titleにもi18nを接続して日本語化しておきます。
(コードは省略。state.settings.i18nを渡して、i18n.settingsをタイトルとして利用します。)

設定画面だけですが、ちゃんとi18nできました。

画像17

画像18

Macの場合、言語設定で優先度を変えれば結果が変わります。

画像19

永続化

今のままではアプリを再起動すると全てのデータが消えてしまいます。
永続化が必要です。

redux-persistを使うと簡単に実装できます。

$ yarn add redux-persist

あとは、App.jsをREADME通りに書き換えるだけです。

// src/App.js

 import { Provider } from 'react-redux';
 import { AppState } from 'react-native';
 import { DangerZone } from 'expo';
+import { persistStore, persistReducer } from 'redux-persist';
+import storage from 'redux-persist/lib/storage';
+import { PersistGate } from 'redux-persist/integration/react';
 
 import reducer from './modules';
 import { setDate } from './modules/today';
 import { setLocale } from './modules/settings';
 import Navigator from './Navigator';
 
-const store = createStore(reducer);
+const persistConfig = {
+  key: 'root',
+  storage,
+};
+const persistedReducer = persistReducer(persistConfig, reducer);
+const store = createStore(persistedReducer);
+const persistor = persistStore(store);
 const { Localization } = DangerZone;
 
 export default class App extends React.Component {
// src/App.js

   render() {
     return (
       <Provider store={store}>
-        <Navigator />
+        <PersistGate loading={null} persistor={persistor}>
+          <Navigator />
+        </PersistGate>
       </Provider>
     );
   }

これでreduxの全データが保存されます。
React NativeではAsyncStorage、RN for WebではlocalStorageが使われます。

この状態で実行すると、リロードしてもカレンダーにデータが残っているはずです。

画像20

AsyncStorage・localStorageは大量データには適さないので、そういう場合はFirebaseを使うなどしたほうがよさそうです。

Face

大まかな部分はできたので細かい箇所を実装していきます。

まずは顔です。
Today画面の下部にisGoodの状態に応じた顔を表示します。

画像を使っても良いのですが、ARTを使えば、この程度の図形であれば簡単に書くことができます。

今回はこのような12マスのグリッドに合わせた3種類の顔を描画します。

画像21

画像22

画像23

描画はFaceコンポーネントで行ないます。

// src/components/Face.js

import React from 'react';
import { Dimensions, StyleSheet, View, ART } from 'react-native';
import PropTypes from 'prop-types';

import { goodFlag, badFlag, textColor } from '../constants';

const { width } = Dimensions.get('window');
const { Surface, Path, Shape } = ART;

const faceWidth = width * 0.2;
const faceHeight = faceWidth;
const eyeX = faceWidth * 0.25;
const eyeY = faceHeight * 0.25;
const eyeRadius = faceWidth * 0.04;
const noseX = faceWidth * 0.5;
const noseY = faceHeight * 0.5 - eyeRadius;
const mouthX = faceWidth * 0.25;
const mouthY = faceHeight * 0.75;
const mouthRadius = faceWidth * 0.4;
const strokeWidth = eyeRadius;

export default class Face extends React.Component {
  render() {
    const { isGood } = this.props;
    const eye1 = Path()
      .moveTo(eyeX, eyeY)
      .move(0, -eyeRadius)
      .arc(0, eyeRadius * 2, 1)
      .arc(0, -eyeRadius * 2, 1)
      .close();
    const eye2 = Path()
      .moveTo(faceWidth - eyeX, eyeY)
      .move(0, -eyeRadius)
      .arc(0, eyeRadius * 2, 1)
      .arc(0, -eyeRadius * 2, 1)
      .close();
    const nose = Path()
      .moveTo(noseX, noseY)
      .line(0, eyeRadius * 2);
    let mouth;
    switch (isGood) {
      case goodFlag:
        mouth = Path()
          .moveTo(mouthX, mouthY)
          .counterArcTo(faceWidth - mouthX, mouthY, mouthRadius);
        break;
      case badFlag:
        mouth = Path()
          .moveTo(mouthX, mouthY)
          .arcTo(faceWidth - mouthX, mouthY, mouthRadius);
        break;
      default:
        mouth = Path()
          .moveTo(mouthX, mouthY)
          .arcTo(faceWidth - mouthX, mouthY, 0);
        break;
    }
    return (
      <View style={[styles.face, { left: width * 0.5 - faceWidth * 0.5 }]}>
        <Surface width={faceWidth} height={faceHeight}>
          <Shape d={eye1} fill={textColor} />
          <Shape d={eye2} fill={textColor} />
          <Shape d={nose} stroke={textColor} strokeWidth={strokeWidth} />
          <Shape d={mouth} stroke={textColor} strokeWidth={strokeWidth} />
        </Surface>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  face: {
    position: 'absolute',
    bottom: 0,
  },
});

Face.propTypes = {
  isGood: PropTypes.number.isRequired,
};

これをTodayで利用します。

// src/components/Today.js

 import PropTypes from 'prop-types';
 
 import { textColor, goodColor, badColor, goodFlag, badFlag } from '../constants';
+import Face from './Face';
 
 const { width } = Dimensions.get('window');
// src/components/Today.js

   };
 
   render() {
-    const { onScrollEndDrag, onScroll } = this.props;
+    const { onScrollEndDrag, onScroll, isGood } = this.props;
     return (
-      <FlatList
-        horizontal
-        bounces={false}
-        data={[{ key: goodColor }, { key: badColor }]}
-        renderItem={({ item, index }) => (
-          <View style={[styles.page, { backgroundColor: item.key }]} key={item.key}>
-            <Text style={styles.pageText}>{index === 0 ? 'GOOD' : 'BAD'}</Text>
-          </View>
-        )}
-        ref={component => (this._flatList = component)}
-        onScrollEndDrag={onScrollEndDrag}
-        onScroll={onScroll}
-      />
+      <View style={styles.container}>
+        <FlatList
+          horizontal
+          bounces={false}
+          data={[{ key: goodColor }, { key: badColor }]}
+          renderItem={({ item, index }) => (
+            <View style={[styles.page, { backgroundColor: item.key }]} key={item.key}>
+              <Text style={styles.pageText}>{index === 0 ? 'GOOD' : 'BAD'}</Text>
+            </View>
+          )}
+          ref={component => (this._flatList = component)}
+          onScrollEndDrag={onScrollEndDrag}
+          onScroll={onScroll}
+        />
+        <Face isGood={isGood} />
+      </View>
     );
   }
 }
 
 const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
   page: {
     width,
     justifyContent: 'center',

これでisGoodの状態に合わせて顔が変わるようになりました。

画像24

画像25

ARTの参考文献

以下がとても参考になりました。
- http://saburesan.hatenablog.com/entry/2017/08/31/154100
- https://github.com/react-native-china/react-native-ART-doc/blob/master/doc.md
- http://daguang.me/2016/08/17/react-native-art-%E7%BB%98%E5%9B%BE%E5%85%A5%E9%97%A8/
- https://github.com/facebook/react-native/blob/master/RNTester/js/ARTExample.js

広告

そろそろ仕上げです。
アプリの大事な収益源である広告を表示します。

ここもプラットフォームで分岐が発生してしまう部分です。
iOS、AndroidはAdMobを使います。定数としてそれぞれのadUnitIdを定義します。Webでは使わないので空で問題ありません。

// src/constants/admob.js

import { Platform } from 'react-native';

let adUnitID;
switch (Platform.OS) {
  case 'ios':
    adUnitID = 'AD_UNIT_ID_FOR_IOS';
    break;
  case 'android':
    adUnitID = 'AD_UNIT_ID_FOR_ANDROID';
    break;
  default:
    adUnitID = '';
    break;
}

export { adUnitID };
// src/constants/index.js

+export { adUnitID } from './admob';
+
 export const textColor = 'rgba(0, 0, 0, 0.87)';
 export const goodColor = '#8bc34a';
 export const badColor = '#ff5252';

広告表示用のAdコンポーネントです。

// src/components/Ad.js

import React from 'react';
import { Platform, View } from 'react-native';
import { AdMobBanner } from 'expo';

import { adUnitID } from '../constants';

export default class Ad extends React.Component {
  render() {
    if (Platform.OS === 'web') {
      return <View />;
    }
    return <AdMobBanner adUnitID={adUnitID} testDeviceID="EMULATOR" />;
  }
}

これをTodayScreenから呼び出せば表示されます。

// src/containers/TodayScreen.js

 import React from 'react';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
-import { Dimensions } from 'react-native';
+import { Dimensions, StyleSheet, View } from 'react-native';
 
 import Today from '../components/Today';
 import { goodFlag, badFlag, defaultFlag } from '../constants';
 import { setIsGood, setIsScrolling } from '../modules/today';
 import { updateDate } from '../modules/calendar';
+import Ad from '../components/Ad';
 
 const { width } = Dimensions.get('window');
// src/containers/TodayScreen.js

   render() {
     const { isGood, isScrolling } = this.props;
     return (
-      <Today
-        onScrollEndDrag={this.onScrollEndDrag}
-        onScroll={this.onScroll}
-        isGood={isGood}
-        isScrolling={isScrolling}
-      />
+      <View style={styles.container}>
+        <Today
+          onScrollEndDrag={this.onScrollEndDrag}
+          onScroll={this.onScroll}
+          isGood={isGood}
+          isScrolling={isScrolling}
+        />
+        <Ad />
+      </View>
     );
   }
 }

+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+});
+
 TodayScreen.propTypes = {
   isGood: PropTypes.number.isRequired,
   isScrolling: PropTypes.bool.isRequired,

これでネイティブアプリでは広告が表示されるようになりました。

画像26

Web版ではHTMLに広告を仕込みます。ここではAdsenseを使います。

// public/index.html

       You need to enable JavaScript to run this app.
     </noscript>
     <div id="root"></div>
+    <div id="ad">
+      <script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>
+      <ins class="adsbygoogle"
+           style="display:block"
+           data-ad-client="AD_CLIENT"
+           data-ad-slot="SLOT"
+           data-ad-format="auto"
+           data-full-width-responsive="true"></ins>
+      <script>
+      (adsbygoogle = window.adsbygoogle || []).push({});
+      </script>
+    </div>
   </body>
   <script src="assets/bundle.js"></script>
 </html>

広告を表示する領域のスタイルを設定します。

// public/app.css

 #root {
   display: flex;
   flex-direction: column;
-  min-height: 100vh;
+  min-height: calc(100vh - 90px);
+}
+
+#ad {
+  width: 100%;
+  height: 90px;
 }

ローカルでは真っ白ですが、https://gooddays.tnantoka.com/ のように下部の白枠に広告が表示されます。

画像27

AdMobとAdsenseを出し分けるモジュールを書けばアプリ側での分岐はなくすことができそうですが、ここでは素朴にこのような構成にしました。

リリース

ついにリリースです。

アイコンはFaceコンポーネントを画像にしたものを使いました。

画像28

Safariは、開発者ツールで要素を選択して右クリックから画像化できます。
Chromeは開発者ツールで要素を選択した状態で、「⌘+⇧+P」から「Capture node screenshot」すれば可能です。

リリース用のビルド作成のため、アイコンなどの情報をapp.jsonで指定します。

// app.json

 {
   "expo": {
-    "sdkVersion": "27.0.0"
+    "sdkVersion": "27.0.0",
+    "name": "GoodDaysClone",
+    "icon": "./icon1024.png",
+    "version": "1.0.0",
+    "ios": {
+      "bundleIdentifier": "app.gooddays.gooddaysclone",
+      "buildNumber": "1"
+    },
+    "android": {
+      "package": "app.gooddays.gooddaysclone",
+      "versionCode": 1
+    }
   }
 }

ネイティブアプリは、以下のコマンドで生成されるバイナリをダウンロードしてそれぞれのストアにあげるだけです。

$ expo build:android
$ expo build:ios

 Web版は以下のコマンドで「public/assets」にリリース用のJSが作成されます。

$ yarn build

Publicディレクトリをそのまま公開すればOKです。
GoodDaysではNetlify上でこのbuildコマンドを実行して公開しています。

凡ミスによるリジェクト

Web版が動いたのが嬉しくて、ついサポートURLに「https://gooddays.tnantoka.com/」を入れて申請したところ、めでたくAppleさんにリジェクトされました。
ちゃんとサポートフォームがあるページを作って申請しましょう。

まとめ

このような手順でGoodDaysのように、3プラットフォームで動くアプリを、ほぼWrite Onceで書くことができました。
細かい調整が必要な部分もありますし、規模が大きくなってきたらどんどんプラットフォームごとに必要な物は増えていくでしょう。

ただ、プラットフォームごとにUIを分ける必要がない、かつ規模の小さいアプリなら可能性のある選択肢ではないでしょうか。

ソースコード

この記事で作ったGoodDaysCloneのソースコードはこちらです。

「GoodDays」としてリリースするために諸々調整したバージョンがこちらです。(実際にストアに上がっているソースコードから広告のID類を削除したもの)






この記事が気に入ったらサポートをしてみませんか?