Help us understand the problem. What is going on with this article?

Ruby on Rails と React Native で作る web & モバイルアプリ [モバイルアプリ編]

イントロダクション

目的

この記事は、Ruby on Rails と React Native で作る web & モバイルアプリ [webアプリ編] の続編です。Ruby on Rails で作った web アプリケーションのリソースを API 経由で React Native アプリケーションから操作します。今回は以下のような web とモバイルの両方に対応したアイパス認証付きのシンプルなタスク管理システムを作っています。

スクリーンショット 2019-12-13 11.25.13.pngスクリーンショット 2019-12-13 11.26.12.png

React Native の演習は行いたいけど Ruby on Rails 編には興味がない方は、Ruby on Rails アプリケーションを以下より clone してデプロイするか、今のところは https://zone-web.herokuapp.com/ を生かしているので、こちらを利用してください。

フレームワーク リポジトリ
Ruby on Rails (web) https://github.com/ogihara-ryo/zone-web
React Native (mobile) https://github.com/ogihara-ryo/zone-mobile

想定読者

私自身がまだモバイルアプリケーションを何もリリースしたことのない初学者であるため、初学者による初学者のための演習になっています。写経のみで進めていける演習になっていますが、多少のプログラミング全般の心得は必要になるかもしれません。私は最近 Ruby on Rails で作った web アプリを React Native アプリ対応しようと思っているのですが、この辺りの参考日本語記事が少なくて苦労しているため、同じような立場の人の助けになれれば嬉しく思います。ただ、筆者は前述の通り完全に素人なので、React Native のベストプラクティス等はまるで理解しておらず、とりあえずシンプルに動くものを作る、ぐらいのモチベーションでこの演習を作りました。そのため、技術的に正しくないコードや誤った説明が含まれている可能性があります。何かお気付きのことがありましたら編集リクエストを頂けると嬉しく思います。

もし、この記事を進めていく上で躓いた場合や理解できないことがあった場合は @OgiharaRyo までご連絡頂くか、現在300人ほど参加している技術質問 slack コミュ二ティを運営していますので、こちらで質問して頂ければと思います。(slack 招待リンク)

環境

2019年12月初旬執筆時点の最新版を使用します。

% node -v
v13.3.0

% npm -v
6.13.2

% watchman -v
4.9.0

% expo-cli -V
3.10.2

インストールに関しては先人の知恵と功績によって特に癖はないと思いますが、Mac で Homebrew が入っている環境であれば以下で大体いけます。いけなかったら何とかしてください。

% brew install node
% npm install npm@latest -g
% brew install watchman
% npm install expo-cli --global

React Native アプリケーションの開発

expo init

早速アプリケーションを作っていきましょう。まずは空っぽの React Native アプリケーションを expo init で作成します。Choose a template と言われるので blank のまま return キーで進みます。

% expo init zone-mobile          
? Choose a template: (Use arrow keys)
  ----- Managed workflow -----
❯ blank                 a minimal app as clean as an empty canvas 
  blank (TypeScript)    same as blank but with TypeScript configuration 
  tabs                  several example screens and tabs using react-navigation 
  ----- Bare workflow -----
  minimal               bare and minimal, just the essentials to get you started 
  minimal (TypeScript)  same as minimal but with TypeScript configuration 

次に <The name of your app visible on the home screen> とホームスクリーンに表示するアプリケーションの名前を尋ねられます。ここでは Zone と入力しておきましょう。

? Please enter a few initial configuration values.
  Read more: https://docs.expo.io/versions/latest/workflow/configuration/ › 50% completed
 {
   "expo": {
     "name": "<The name of your app visible on the home screen>",
     "slug": "zone-mobile"
   }
 }

起動確認

早速このまま起動してみましょう。

% expo start

ブラウザーに localhost:19002 が立ち上がります。画面左下に注目してください。

スクリーンショット 2019-12-08 2.03.46.png

開発中アプリケーションの動作確認方法は大きく分けると、シミュレーターを使う方法と実機を使う方法の2つがあります。Xcode と Command Line Tools をインストール済みのマシンであれば、Run on iOS simulator を選択するとシミュレーターが起動します。シミュレーターから Expo アプリケーションを起動してよしなに操作すれば下記のような画面が表示されます。

スクリーンショット 2019-12-08 2.02.57.png

実機を使う場合は、お手元のスマホでストアから Expo Client をインストールして、localhost:19002 に表示されているあなた専用の QR コードを読み込めば Expo Client アプリが起動して上記のような画面が表示されます。ちなみに私は通常 iPhone と Pixel の2台の実機を使いながら iOS と Android の両方を並行して動作確認しながら開発しています。

react-navigation

今回開発するアプリケーションは2つの画面を持ちます。1つはログインフォームを持つログイン画面、もう1つはタスク管理を行うメイン画面です。開発のはじめの第一歩としては、ログイン画面とメイン画面を用意して、お互いを行き来できるような実装を行います。前準備として、react-navigation をインストールします。react-navigation は画面間の遷移をハンドリングしてくれるライブラリです。ドキュメント に従って react-navigation 及びその依存ライブラリ react-native-gesture-handler, react-native-reanimated, react-native-screensexpo install します。

% expo install react-navigation react-native-gesture-handler react-native-reanimated react-native-screens

それではナビゲーションを実装していきます。今回は、App.js にナビゲーションを置いて、src/screens/Login.jssrc/screens/Main.js を互いに遷移できるようなコードを書いていきます。まずはログイン画面とメイン画面を用意します。ただお互いの画面に遷移するためのボタンが1つだけ置いてある簡素な画面です。ButtononPress イベントで this.props.navigation.navigate('main') のようなメソッドコールを行っているのがポイントです。

src/screens/Login.js
import React from 'react';
import { View, Button } from 'react-native';

export default class Login extends React.Component {
  render() {
    return (
      <View>
        <Button
          title="ログイン"
          onPress={() => {this.props.navigation.navigate('main')}}>
        </Button>
      </View>
    );
  }
}
src/screens/Main.js
import React from 'react';
import { View, Button } from 'react-native';

export default class Main extends React.Component {
  render() {
    return (
      <View>
        <Button
          title="ログアウト"
          onPress={() => {this.props.navigation.navigate('login')}}>
        </Button>
      </View>
    );
  }
}

作った2つの画面をナビゲーターにセットします。今回は、createSwitchNavigator を使用します。

App.js
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { createAppContainer, createSwitchNavigator } from 'react-navigation';

import Login from './src/screens/Login'
import Main from './src/screens/Main'

export default function App() {
  const MainNavigator = createAppContainer(
    createSwitchNavigator({
      login: { screen: Login },
      main: { screen: Main }
    })        
  )

  return (
    <View style={styles.container}>
      <MainNavigator />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

これで2画面を遷移できるアプリケーションになりました。

スクリーンショット 2019-12-08 9.51.50.pngスクリーンショット 2019-12-08 9.51.53.png

ログイン

それでは、本格的にログインフォームを実装していきましょう。解説は後にして、一旦全てのコードを一気に実装してしまいましょう。記事内の web アプリケーションの URL は、ご自身のアプリケーションをデプロイされていたら差し替えてください。

src/screens/Login.js
import React from 'react';
import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet } from 'react-native';

export default class Login extends React.Component {
  constructor(props) {
    super(props);
    this.state = { accountId: '', password: '', loading: false, failed: false };
  }

  onSubmit() {
    this.setState({ loading: true })
    return (
      fetch(`https://zone-web.herokuapp.com/api/login.json?account_id=${this.state.accountId}&password=${this.state.password}`)
        .then((response) => response.json())
        .then((jsonData) => {
          this.setState({ loading: false })
          if (jsonData['api_token']) {
            this.props.navigation.navigate('main')
          }
          else {
            this.setState({ failed: true })
          }
        })
        .catch((error) => console.error(error))
    )
  }

  loginButton() {
    if (this.state.loading) {
      return <ActivityIndicator size="small" />
    }
    else {
      return <Button title="ログイン" onPress={() => {this.onSubmit()}} />
    }
  }

  render() {
    return (
      <View>
        {this.state.failed && <Text>ログインに失敗しました。</Text>}

        <TextInput
          style={styles.textInput}
          placeholder="アカウントID"
          onChangeText={(accountId) => this.setState({accountId})}
        />

        <TextInput
          secureTextEntry={true}
          style={styles.textInput}
          placeholder="パスワード"
          onChangeText={(password) => this.setState({password})}
        />

        {this.loginButton()}
      </View>
    );
  }
}

const styles = StyleSheet.create({
  textInput: {
    height: 60,
    width: 300,
    paddingLeft: 20,
    margin: 10,
    borderWidth: 1,
    borderRadius: 8,
  }
});

まずは動かしてみましょう。誤ったアカウントIDやパスワードでログインを試みたらエラーメッセージが表示され、正しいアカウントIDとパスワードでログインを試みたらメイン画面へ遷移することを確認してください。

スクリーンショット 2019-12-08 16.06.41.png

さて、今回実装したのは比較的長いコードではありますが、この Login コンポーネントが何をしているかは、各 state がどのタイミングで更新されて、何に使われているのかを知ることで全容を把握することができます。コードと読み比べてみましょう。

state 更新タイミング 用途
accountId アカウントIDの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。 web アプリケーションのログイン API に与えるパラメーターとしてセットします。
password パスワードの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。 web アプリケーションのログイン API に与えるパラメーターとしてセットします。
loading web アプリケーションに fetch でリクエストを送る前に true にし、レスポンスを受け取った後に false にします。 ログインボタンが押された後、web アプリケーションと通信している間にログインボタンを ActivityIndicator (いわゆるロード中のクルクル)に差し替えることで、ユーザーに通信中であることを伝えると同時に、ログインボタンを不要に連打されないようにします。
failed ログイン API を叩いて API トークンが返ってこなかった場合はアカウントIDかパスワードが間違っていたとして true にします。 true の場合にログインに失敗した旨をユーザーに通知します。

APIトークンの保存とログインの永続化

ここまでのコードでは、APIトークンが返ってきた場合はログインに成功したとしてナビゲーターをメイン画面に変更していますが、このAPIトークンは後のタスク操作 API をコールするために使用するのでちゃんと保存しておく必要があります。また、API トークンを一度受け取ってしまえば次回以降のアプリケーション起動時はログイン画面を挟まずにメイン画面を表示した方が親切です。今回は AsyncStorage というキーバリューストレージシステムに API トークンを保存します。

src/screens/Login.js
  import React from 'react';
- import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet } from 'react-native';
+ import { View, Text, Button, TextInput, ActivityIndicator, StyleSheet, AsyncStorage } from 'react-native';

  export default class Login extends React.Component {
    constructor(props) {
      super(props);
      this.state = { accountId: '', password: '', loading: false, failed: false };
    }
+
+   async componentDidMount() {
+     if (await AsyncStorage.getItem('api_token')) {
+       this.props.navigation.navigate('main')
+     }
+   }

    onSubmit() {
      this.setState({ loading: true })
      return (
        fetch(`https://zone-web.herokuapp.com/api/login.json?account_id=${this.state.accountId}&password=${this.state.password}`)
          .then((response) => response.json())
          .then((jsonData) => {
            this.setState({ loading: false })
            if (jsonData['api_token']) {
+             AsyncStorage.setItem('api_token', jsonData['api_token']);
              this.props.navigation.navigate('main')
            }
            else {
              this.setState({ failed: true })
            }
          })
          .catch((error) => console.error(error))
      )
    }

componentDidMount は、コンポーネントがツリーに挿入された直後に呼び出されます。この時、AsyncStorage に既に API トークンが入っていた場合はナビゲーターをメイン画面に切り替えています。

ログアウト

最後に、ログアウトの実装も行いましょう。これはシンプルで、AsyncStorage から API トークンを削除してナビゲーターをログイン画面へ切り替えるだけです。

src/screens/Main.js
  import React from 'react';
- import { View, Button } from 'react-native';
+ import { View, Button, AsyncStorage } from 'react-native';

  export default class Main extends React.Component {
+   logout() {
+     AsyncStorage.removeItem('api_token');
+     this.props.navigation.navigate('login')
+   }

    render() {
      return (
        <View>
          <Button
            title="ログアウト"
-           onPress={() => {this.props.navigation.navigate('login')}}>
+           onPress={() => {this.logout()}}>
          </Button>
        </View>
      );
    }
  }

タスクの CRUD

各処理の雑な解説は後回しにして、まずは完成形です。アクションも DOM のレンダリングも全て同じファイルにまとめているので100行ほどあります。

スクリーンショット 2019-12-13 11.26.12.png

src/screens/Main.js
import React from 'react';
import { View, FlatList, TextInput, Button, AsyncStorage, ActivityIndicator, StyleSheet } from 'react-native';
import { CheckBox } from 'react-native-elements';

export default class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = { taskName: '', tasks: [], loading: '', apiToken: '' };
  }

  async componentDidMount() {
    this.setState({ loading: true, apiToken: await AsyncStorage.getItem('api_token') })
    fetch(`https://zone-web.herokuapp.com/api/tasks.json?api_token=${this.state.apiToken}`)
      .then((response) => response.json())
      .then((jsonData) => (this.setState({ loading: false, tasks: jsonData })))
      .catch((error) => console.error(error));
  }

  submitCreateTask() {
    if (!this.state.taskName) return

    this.setState({ loading: true })
    fetch(`https://zone-web.herokuapp.com/api/tasks`, {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { name: this.state.taskName } })
    })
    .then(response => response.json())
    .then(json => { this.setState({ tasks: this.state.tasks.concat(json), taskName: '', loading: false }) })
    .catch((error) => console.error(error));
  }

  changeFinished(item) {
    item.finished = !item.finished
    this.setState({ loading: true })

    fetch(`https://zone-web.herokuapp.com/api/tasks/${item.id}`, {
      method: 'PATCH',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { finished: item.finished } })
    })
    .then(response => this.setState({ loading: false }))
    .catch((error) => console.error(error));
  }

  createTaskButton() {
    if (this.state.loading) return <ActivityIndicator size="small" />
    else return <Button title="作成" onPress={() => {this.submitCreateTask()}} />
  }

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

  logout() {
    AsyncStorage.removeItem('api_token');
    this.props.navigation.navigate('login')
  }

  render() {
    return (
      <View>
        <View style={styles.form}>
          <TextInput
            style={styles.textInput}
            placeholder="タスク名"
            value={this.state.taskName}
            onChangeText={(taskName) => this.setState({taskName})}
          />

          {this.createTaskButton()}
        </View>

        {this.renderTasks()}

        <View style={styles.logout}>
          <Button title="ログアウト" onPress={() => {this.logout()}} />
        </View>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  form: { margin: 40 },
  textInput: {
    height: 60,
    width: 300,
    paddingLeft: 20,
    margin: 10,
    borderWidth: 1,
    borderRadius: 8,
  },
  logout: { marginBottom: 20 }
});

react-native-elementsCheckBox を利用するのでインストールします。

% expo install react-native-elements

state

まずはログイン画面と同様に Main コンポーネントの持つ各 state の動きを把握しましょう。

state 更新タイミング 用途
taskName タスク作成フォームの TextInput にユーザーが入力する度に onChangeText イベントで現在の値がセットされます。また、タスク作成ボタンを押した時に TextInput から現在の値を空にするために空文字列をセットします。 web アプリケーションのタスク作成 API に与えるパラメーターとしてセットします。
tasks componentDidMount で web アプリケーションのタスク一覧 API からタスク情報を取得してセットします。また、各タスクの完了状態のチェックボックスを操作した時に、該当のタスクの finished の値をチェックボックスの状態に応じて変更します。 タスク一覧の表示に使います。
loading ログイン画面と同様、web アプリケーションに fetch でリクエストを送る前に true にし、レスポンスを受け取った後に false にします。 ログイン画面と同様、タスク一覧の取得、タスクの作成、タスクの完了状態の更新の API をコールした後、web アプリケーションと通信している間に各種 UI を ActivityIndicator に差し替えることで、ユーザーに通信中であることを伝えると同時に、不要な操作をされないようにします。この挙動は UX を損ねるので気に入らなければ外してしまってユーザーに通信していることを意識させないようにしても良いでしょう。今回は web アプリケーションとやり取りしていることを明示するために操作の度にクルクルが表示されます。
apiToken componentDidMountAsyncStorage から API トークンを取り出してセットします。 毎回 await AsyncStorage.getItem('api_token') してストレージアクセスするのは非効率なので、一度 API トークンを取得したら state にキャッシュしています。

表示

まずはタスク一覧の表示についてです。実際の表示部分は下記です。

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

ロード中(web アプリケーションとの通信中)は空っぽの FlatList を表示し、ロード中でない場合は tasks に従ってアイテムを表示しています。React Native では、コンポーネントの state が更新された時にそのコンポーネントの render メソッドが実行されます。つまり、loadingstate が更新される度にこのコンポーネントの render() が走るため、if に入ってきたり else に入ってきたりするわけですね。この FlatList には this.state.tasks を与えて CheckBox をレンダリングしていますが、this.state.tasks に値が入るのは以下の処理です。

  async componentDidMount() {
    this.setState({ loading: true, apiToken: await AsyncStorage.getItem('api_token') })
    fetch(`https://zone-web.herokuapp.com/api/tasks.json?api_token=${this.state.apiToken}`)
      .then((response) => response.json())
      .then((jsonData) => (this.setState({ loading: false, tasks: jsonData })))
      .catch((error) => console.error(error));
  }

タスクの一覧は componentDidMount で行っています。componentDidMount は、render メソッドによる DOM 挿入直後に呼び出されます。このコンポーネントでは componentDidMount で web アプリケーションからタスク一覧を取得しているのでタスク一覧表じまでの順序としては以下のようになります。

  1. 初回の render で 空の FlatList を表示
  2. componentDidMount で API からタスクの一覧を取得
  3. 取得後に loadingtasksstate が変更されたことで再度 render が実行されてタスク一覧を表示

作成

続いてタスクの作成機能について見ていきましょう。render メソッド内のタスク作成フォーム部分は以下です。

        <View style={styles.form}>
          <TextInput
            style={styles.textInput}
            placeholder="タスク名"
            value={this.state.taskName}
            onChangeText={(taskName) => this.setState({taskName})}
          />

          {this.createTaskButton()}
        </View>

今までと同様、ロード中は ActivityIndicator のクルクルを、ロード中でない場合は作成ボタンをレンダリングします。

  createTaskButton() {
    if (this.state.loading) return <ActivityIndicator size="small" />
    else return <Button title="作成" onPress={() => {this.submitCreateTask()}} />
  }

作成ボタンの onPress イベントで呼ばれるのが以下です。タスク名の入力フィールドに何も入力されていなかった場合は何もせずに終了し、何か入力されていた場合は web アプリケーションのタスク作成 API をコールしています。レスポンスを受けたら、tasksstate に今追加したタスクの情報を詰めて、taskName を空にすることでタスク入力フォームのタスク名入力フィールド(TextInput)を空にしています。

  submitCreateTask() {
    if (!this.state.taskName) return

    this.setState({ loading: true })
    fetch(`https://zone-web.herokuapp.com/api/tasks`, {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { name: this.state.taskName } })
    })
    .then(response => response.json())
    .then(json => { this.setState({ tasks: this.state.tasks.concat(json), taskName: '', loading: false }) })
    .catch((error) => console.error(error));
  }

更新

最後はチェックボックスの状態を変更した時の完了状態更新処理です。チェックボックスのレンダリング部分は、タスク一覧を表示した時の FlatList の各アイテムの中にあります。

  renderTasks() {
    if (this.state.loading) return <FlatList />
    else {
      return(
        <FlatList
          data={this.state.tasks}
          keyExtractor={(item) => item.id.toString()}
          renderItem={({ item }) => (
            <CheckBox title={item.name} checked={item.finished} onPress={() => this.changeFinished(item)} />
          )}
        />
      )
    }
  }

CheckBoxonPress イベントで呼ばれるのが以下です。引数の item は参照渡しなので、item.finished の論理を反転すると tasksstate が更新されます。

  changeFinished(item) {
    item.finished = !item.finished
    this.setState({ loading: true })

    fetch(`https://zone-web.herokuapp.com/api/tasks/${item.id}`, {
      method: 'PATCH',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ api_token: this.state.apiToken, task: { finished: item.finished } })
    })
    .then(response => this.setState({ loading: false }))
    .catch((error) => console.error(error));
  }

以上で React Native の演習は終了です。

終わりに

今回は2記事構成にて Ruby on Rails アプリケーションと React Native アプリケーションの連携を行いました。冒頭にも書きましたが、技術的に正しくないコードや誤った説明があったかもしれないので、何かお気付きのことがありましたら編集リクエストを頂けると嬉しく思います。React Native アプリケーション開発の日本語入門記事は Firebase を使ってのものが多く、既存の web アプリケーションに API を生やしつつモバイル対応するようなシチュエーションを想定した演習は少なく思えたので、本記事がどなたかの役に立てば嬉しく思います。

Ruby on Rails Advent Calendar 17日目は canecco さんの「ローカル通知に画像を表示する話」です。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした