イントロダクション
目的
この記事は、Ruby on Rails と React Native で作る web & モバイルアプリ [webアプリ編] の続編です。Ruby on Rails で作った web アプリケーションのリソースを API 経由で React Native アプリケーションから操作します。今回は以下のような web とモバイルの両方に対応したアイパス認証付きのシンプルなタスク管理システムを作っています。
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 が立ち上がります。画面左下に注目してください。
開発中アプリケーションの動作確認方法は大きく分けると、シミュレーターを使う方法と実機を使う方法の2つがあります。Xcode と Command Line Tools をインストール済みのマシンであれば、Run on iOS simulator
を選択するとシミュレーターが起動します。シミュレーターから Expo アプリケーションを起動してよしなに操作すれば下記のような画面が表示されます。
実機を使う場合は、お手元のスマホでストアから 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-screens を expo install
します。
% expo install react-navigation react-native-gesture-handler react-native-reanimated react-native-screens
それではナビゲーションを実装していきます。今回は、App.js
にナビゲーションを置いて、src/screens/Login.js
と src/screens/Main.js
を互いに遷移できるようなコードを書いていきます。まずはログイン画面とメイン画面を用意します。ただお互いの画面に遷移するためのボタンが1つだけ置いてある簡素な画面です。Button
の onPress
イベントで this.props.navigation.navigate('main')
のようなメソッドコールを行っているのがポイントです。
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>
);
}
}
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
を使用します。
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画面を遷移できるアプリケーションになりました。
ログイン
それでは、本格的にログインフォームを実装していきましょう。解説は後にして、一旦全てのコードを一気に実装してしまいましょう。記事内の web アプリケーションの URL は、ご自身のアプリケーションをデプロイされていたら差し替えてください。
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とパスワードでログインを試みたらメイン画面へ遷移することを確認してください。
さて、今回実装したのは比較的長いコードではありますが、この 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 トークンを保存します。
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 トークンを削除してナビゲーターをログイン画面へ切り替えるだけです。
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行ほどあります。
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-elements
の CheckBox
を利用するのでインストールします。
% 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 |
componentDidMount で AsyncStorage から 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
メソッドが実行されます。つまり、loading
の state
が更新される度にこのコンポーネントの 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 アプリケーションからタスク一覧を取得しているのでタスク一覧表じまでの順序としては以下のようになります。
- 初回の
render
で 空のFlatList
を表示 -
componentDidMount
で API からタスクの一覧を取得 - 取得後に
loading
とtasks
のstate
が変更されたことで再度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 をコールしています。レスポンスを受けたら、tasks
の state
に今追加したタスクの情報を詰めて、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)} />
)}
/>
)
}
}
CheckBox
の onPress
イベントで呼ばれるのが以下です。引数の item
は参照渡しなので、item.finished
の論理を反転すると tasks
の state
が更新されます。
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 さんの「ローカル通知に画像を表示する話」です。