bo-tech

heart and tech with love.

react native tutorial 日本語で(2015/10/24)

bo-tech.hatenablog.com

先日の記事でiosandroid共に実機デバッグができるようになった。
なので次のステップに。

チュートリアルを試す

facebook.github.io

reactも触ったことがないので、雰囲気掴むためにチュートリアルをやってみる。
ついでになんとなく日本語に訳しておこう。

ただ毎回二つのデバイスで試験するのも手間なので、
iosのみで実装し、最後にandroid用の修正をすることにしよう。

前置き

iosandroidの開発をreact nativeを利用して書く。
react nativeとは、とか、facebookがreact nativeを書いた理由とかは ここ を参照。

Reactの経験があることを仮定としているから、もし触れたことがなければReactで確認を。

(あれ、俺このまま進んでもいいのかね。。。)

セットアップ

依存関係と基本的なセットアップはここを参照のこと。

もろもろ依存関係とか解決すると以下の二つのコマンドが使用できるreact native用の開発環境が整う。

  1. sh npm install -g react-native-cli
    コマンドラインインターフェースのインストール。

  2. sh react-native init AwesomeProject
    ReactNativeのソースコードや依存関係の解決をした後に、Xcodeとgradleのプロジェクトを作成。

開発

iosの場合は、Xcodeでproject(AwesomeProject/ios/AwesomeProject.xcodeproj)を開き、⌘+Rでビルドと実行をすれば起動。ちなみにこれはライブコードリロードを可能にするnode serverが起動している。この機能によって、シミュレーター内で⌘+Rを押すと全ての変更を確認できるようになる。

androidの場合は sh react-native run-android のコマンドを実行するとアプリを生成してエミュレーターか実機にインストールする。んで、ライブコードリロード用のnode serverが起動する。変更を確認するためには、rage-shake-menu (実機を振る、menuボタンを押す、F2ボタンを押す、エミュレーターならPageUpを押す、Genymotionが入っているなら⌘+M、このいずれかでメニューが出てくる)を開いて、ReloadJSを押すんだ。

Hello World

sh react-native init でアプリを作成。今回はAwesomePorjectという名前にした。これは簡単なhello worldアプリになっている。

iosではindex.ios.jsを編集して、⌘+Rを押せば変更が確認できる。
androidではindex.android.jsを編集して、メニューを開いて、ReloadJSを押せば変更が確認できる。

モックデータ

Rotten Tomatoes(海外の映画評論家のコメントが載っているサイトらしい)からデータを取得するコードを書く前に、ReactNativeで遊ぶためのモックデータを用意しよう。facebookでは一般的に定数はJSファイルの上の方に書く、ちょうどrequiresの下だ。しかしこの辺は好き好きで自由にしていい。

ということで、index.ios.jsかindex.android.jsに以下の記載をする。

var MOCKED_MOVIES_DATA = [
  {title: 'Title'
  , year: '2015'
  , posters: {thumbnail: 'http://i.imgur.com/UePbdph.jpg'}
  },
];

MovieをRenderする

さて、ここでは映画に関するtitle, year, thumbnailをrenderすることにしよう。
thumbnailがReact NativeのImage componentなので、ImageをReactのrequiresに加えて以下のようにしよう。

var {
  AppRegistry,
  Image,
  StyleSheet,
  Text,
  View,
} = React;

次に、さきほど作ったモックデータに関してrenderするようにrender関数を変更してみよう。

  render: function() {
    var movie = MOCKED_MOVIES_DATA[0];
    return (
      <View style={styles.container}>
        <Text>{movie.title}</Text>
        <Text>{movie.year}</Text>
        <Image source={{uri: movie.posters.thumbnail}} />
      </View>
    );
  }

⌘+R または ReloadJSを押し、Titleと2015が表示されるのを確認。ここで画像がまだ表示されていないのに気づいたかな。これはwidthとheightを指定していないからである。
このサイズの指定に関してはstyleを経由して行うよ。Styleを以下のように変更し、他の内容に関してはいらないので削除してしまおう。

var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  thumbnail: {
    width: 53,
    height: 81,
  },
});

そして最後にこのstyleをImageコンポーネントに適用する。

        <Image
          source={{uri: movie.posters.thumbnail}}
          style={styles.thumbnail}
        />

⌘+R または ReloadJSで更新だ。

styleを追加する

これでstyleを追加できたので、より良い見た目に変更していく。
画像を左寄せで、テキストを右寄せ、さらにタイトルを大きくエリア内で中央寄せにする。

まずは、containerをviewに追加しよう。これは水平方向にレイアウトするための要素となる。

      return (
        <View style={styles.container}>
          <Image
            source={{uri: movie.posters.thumbnail}}
            style={styles.thumbnail}
          />
          <View style={styles.rightContainer}>
            <Text style={styles.title}>{movie.title}</Text>
            <Text style={styles.year}>{movie.year}</Text>
          </View>
        </View>
      );

そんなに大きな変更をする必要はない。Imageを移動させ、囲むだけだ。
styleは以下のようになる。

  container: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },

ここではFlexBoxレイアウトを指定している。
ちなみに上のコードでは、単純にflexDirection: 'row'をメインContainerに指定して、水平方向に並べるための子要素を作っている。

さらに追加で以下のstyleも追加しよう。

  rightContainer: {
    flex: 1,
  },

このrightContainerの意味は、親containerに対して、余ったスペースを埋めるというものである。注意したいのはImageが余白を埋めるのではないという点である。もし理解ができなければ、backgroundColorをrightContainerに追加し、flex:1の指定を外してみれば良いだろう。
そうすれば、containerのサイズが子要素に対して最小化されるというのが確認できるかと。

最後に以下のstyleを足してみる。

  title: {
    fontSize: 20,
    marginBottom: 8,
    textAlign: 'center',
  },
  year: {
    textAlign: 'center',
  },

最後にいつもどおり⌘+R または ReloadJSで確認だ。

実データの取得

Rotten TomatesのAPIを利用して、リアルなデータを取得してみよう。
まぁこれはReactNativeの学習ではないので息抜き程度に気楽にやってほしい。

以下のリクエストに利用する定数REQUEST_URLをjavascriptの上部に足す。(一般的にはrequiresの下になるが)

/**
 * For quota reasons we replaced the Rotten Tomatoes' API with a sample data of
 * their very own API that lives in React Native's Github repo.
 */
var REQUEST_URL = 'https://raw.githubusercontent.com/facebook/react-native/master/docs/MoviesExample.json';

次に、アプリの初期状態を追加する。これは、 javascript this.state.movies === null をチェックすることによりコンテンツがロードされているかどうかを確認することができるようにするためだ。

このデータをセットするタイミングは、レスポンスが返ってきた時に javascript this.setState({movies: moviesData}) がコールされた時である。

以下のコードをrender関数の上に追加してみよう。

  getInitialState: function() {
    return {
      movies: null,
    };
  },

requestはコンポーネントのロードが終了したら送出したい。そこで、Reactコンポーネントのロードが完了したタイミングで一度だけ呼ばれるcomponentDidMountという関数を実装する。

  componentDidMount: function() {
    this.fetchData();
  },

いよいよ、このコンポーネントが利用するfetchData関数を追加する。
この関数はデータ取得に関しての責任を持つことになる。
ここで必要なことは、promise chainが解決された後に javascript this.setState({movies: data}) を呼ぶことである。
なぜならReactのsetState関数は再描画のトリガーとなっており、render関数におけるthis.state.moviesがnullではなくなる必要があるためである。
注意すべき事項としては、doneという関数をpromise chainの最後にコール必要がある。いつもdone()が呼ばれていることを確認しよう、さもなくば即座にerrorが飛んでくることになる。

  fetchData: function() {
    fetch(REQUEST_URL)
      .then((response) => response.json())
      .then((responseData) => {
        this.setState({
          movies: responseData.movies,
        });
      })
      .done();
  },

あとは、render関数をmovieデータを持っていない時のloading用viewに変更するように変更を行い、そうでなければ一番最初のmovieデータを表示するようにする。

  render: function() {
    if (!this.state.movies) {
      return this.renderLoadingView();
    }

    var movie = this.state.movies[0];
    return this.renderMovie(movie);
  },

  renderLoadingView: function() {
    return (
      <View style={styles.container}>
        <Text>
          Loading movies...
        </Text>
      </View>
    );
  },

  renderMovie: function(movie) {
    return (
      <View style={styles.container}>
        <Image
          source={{uri: movie.posters.thumbnail}}
          style={styles.thumbnail}
        />
        <View style={styles.rightContainer}>
          <Text style={styles.title}>{movie.title}</Text>
          <Text style={styles.year}>{movie.year}</Text>
        </View>
      </View>
    );
  },

いつもの通り⌘+R / ReloadJS を押して、"Loading movies... " と表示されることを確認し、待っていると最初の動画データが表示されるのを確認してみよう。

ListView

ListViewコンポーネントを利用して、アプリケーションを変更してみよう。

このmovie要素を描画雨する際に、ScrollViewよりもListViewの方が適しているのは何故だろうか。
Reactが早いにもかかわらず、無限の要素を描画する際には遅くなってしまう。
ListViewはViewの描画をスケジューリングすることにより、すでに描画されている要素をスクリーン上に表示し、スクリーンを外れたものはnative viewの構造から取り除くという仕組みをもっている。

まず最初にやることは、ListViewをrequireに追加することである。

var {
  AppRegistry,
  Image,
  ListView,
  StyleSheet,
  Text,
  View,
} = React;

次に、render関数を単一のmovieデータを表示するロジックから、一度に取得したデータをListViewへ描画するというロジックに変更する。

  render: function() {
    if (!this.state.loaded) {
      return this.renderLoadingView();
    }

    return (
      <ListView
        dataSource={this.state.dataSource}
        renderRow={this.renderMovie}
        style={styles.listView}
      />
    );
  },

ここでdataSourceとは、ListViewがデータの更新時に行が変わったかどうかを特定するために利用するインターフェースである。

javascript this.state からdataSourceが利用されているのが確認できるかと思う。

次のステップでは、getInitialStateから返されるobjectに空のdataSourceを追加する。
また、dataSourceにdataを保持することにする。そのため、データの重複保持を避けるためにthis.state.moviesは今後利用することはない。さらにthis.state.loadedのbool値を用いて、dataのfetchが終了したか否かを判断する。

  getInitialState: function() {
    return {
      dataSource: new ListView.DataSource({
        rowHasChanged: (row1, row2) => row1 !== row2,
      }),
      loaded: false,
    };
  },

fetchData関数も以下のように現状に倣って変更する。

  fetchData: function() {
    fetch(REQUEST_URL)
      .then((response) => response.json())
      .then((responseData) => {
        this.setState({
          dataSource: this.state.dataSource.cloneWithRows(responseData.movies),
          loaded: true,
        });
      })
      .done();
  },

最後に、ListViewへのスタイルを追加する。

  listView: {
    paddingTop: 20,
    backgroundColor: '#F5FCFF',
  },

これで完了だ。

ナビゲーションを追加したり、検索を追加したり、無限ローディング機能を追加したりなどやることは残っているが、(Movie Example)https://github.com/facebook/react-native/tree/master/Examples/Moviesを参照してくれ!

最終ソースコード

/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 */
'use strict';

var React = require('react-native');
var {
  AppRegistry,
  Image,
  ListView,
  StyleSheet,
  Text,
  View,
} = React;

var API_KEY = '7waqfqbprs7pajbz28mqf6vz';
var API_URL = 'http://api.rottentomatoes.com/api/public/v1.0/lists/movies/in_theaters.json';
var PAGE_SIZE = 25;
var PARAMS = '?apikey=' + API_KEY + '&page_limit=' + PAGE_SIZE;
var REQUEST_URL = API_URL + PARAMS;

var AwesomeProject = React.createClass({
  getInitialState: function() {
    return {
      dataSource: new ListView.DataSource({
        rowHasChanged: (row1, row2) => row1 !== row2,
      }),
      loaded: false,
    };
  },

  componentDidMount: function() {
    this.fetchData();
  },

  fetchData: function() {
    fetch(REQUEST_URL)
      .then((response) => response.json())
      .then((responseData) => {
        this.setState({
          dataSource: this.state.dataSource.cloneWithRows(responseData.movies),
          loaded: true,
        });
      })
      .done();
  },

  render: function() {
    if (!this.state.loaded) {
      return this.renderLoadingView();
    }

    return (
      <ListView
        dataSource={this.state.dataSource}
        renderRow={this.renderMovie}
        style={styles.listView}
      />
    );
  },

  renderLoadingView: function() {
    return (
      <View style={styles.container}>
        <Text>
          Loading movies...
        </Text>
      </View>
    );
  },

  renderMovie: function(movie) {
    return (
      <View style={styles.container}>
        <Image
          source={{uri: movie.posters.thumbnail}}
          style={styles.thumbnail}
        />
        <View style={styles.rightContainer}>
          <Text style={styles.title}>{movie.title}</Text>
          <Text style={styles.year}>{movie.year}</Text>
        </View>
      </View>
    );
  },
});

var styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  rightContainer: {
    flex: 1,
  },
  title: {
    fontSize: 20,
    marginBottom: 8,
    textAlign: 'center',
  },
  year: {
    textAlign: 'center',
  },
  thumbnail: {
    width: 53,
    height: 81,
  },
  listView: {
    paddingTop: 20,
    backgroundColor: '#F5FCFF',
  },
});

AppRegistry.registerComponent('AwesomeProject', () => AwesomeProject);