見出し画像

【両OS対応】 React Nativeで爆速プロトタイプアプリを作ろう 3/3 【プラスボタン編】


本記事は2018年5月に執筆されました.当時の React Native の環境から変わっている部分もありますので,2020年5月より記事の値段を大幅値下げすると同時に,記事更新や質問等のサポートの対象外とさせて頂きます.
m(_ _ )m

1/3 【ウェルカム画面編】:799円 → 199円
2/3 【ホームタブ編】:899円 → 299円
3/3 【プラスボタン編】:999円 → 399円

1. ウェルカム画面編
2. ホームタブ編
3. プラスボタン編 ← 今ココ

画像1

note.muの中の方からもコメント頂けました!ありがとうございます!

画像2

↑前回記事までの目標

↑この記事での目標(最終完成形)

この記事で得れる物

・上図のスマホアプリが作れるようになる
・React Nativeの流れを図で直感的にわかる
・なぜこのようにプログラミングをしたのかまでわかる

この記事の対象者

・アプリ開発経験そんなない方
・Web系開発経験そんなない方
・でもまあほんのちょっとプログラミングかじった事ある方

大丈夫です、僕も当時こんな感じでした。Swift(iOS用プログラミング言語)もKotlin(Android用プログラミング言語)も知らなければ、よく聞くjQueryすら触った事ないほどJavaScript(Web系プログラミング言語)に対する知識もありませんでした。それでも習得できたので大丈夫です。筆者が水先案内人として読者の方々を、「後は自分でググればいける」というレベルまで引き上げます。また、Windowsでも開発できますが、本記事ではMacで作る事を想定してます。

React Nativeとは?

本来ならiOSアプリとAndroidアプリは別言語で書かなければいけなかったのですが、2015年くらいにiOSとAndroidの両アプリを同時に書けちゃう画期的なプログラミング言語をFacebook社が開発しました。それがReact Nativeです(正確にはReact Nativeはプログラミング言語ではなくてライブラリというやつで、実際はJavaScriptって言語で書きます)。

処理速度も速く、高く評価されており、FacebookやInstagramは元よりAirbnbなどの超有名アプリもReact Nativeにどんどん乗り換えています。日本製アプリで言うとメルカリProgateもReact Nativeだそうです。理由は一言で言うと、開発スピードが速いからです。トレンドの移り変わりが激しい現代のITベンチャーにおいてこれほど嬉しい利点は他にないでしょう。

この記事の目的は?

と、つまり激アツなプログラミング言語なのですがいかんせん日本語のまとまった情報が少なく、僕自身学ぶ際に非常に苦労したのでこのチュートリアル記事を執筆することにしました。

「起業したい!良いアイデアを思いついた!でもアプリ作ったことないし、だからといって周りにエンジニアもいない……よしここは一丁自分でプロトタイプを作ってみよう。Instagramもメルカリも最初はプログラミング未経験から始めたんだし!」
……と意気込んで調べてみるものの、出てくるQiitaなどの記事はどれも既にある程度の知識を持ってる前提で話が進んでることがしばしば。いやソースコードだけ貼られてもわかんねえし、みたいな。

そこでこのReact Nativeを初学者でも簡単に学べれるようになれば、iOS/Androidの両アプリを一気に作れちゃうし、爆速で手元にある実機で動作確認できるし、リリース後はApp Storeの審査を待たずに細かな修正をできるし……と言ったベンチャーにとって非常に有利になる武器を持った状態からスタートできます。そして何より僕自身「アプリってこんな簡単に作れるんだ!」と感動したのでその感動をもっと広めたく執筆しました。

SwiftもKotlinもJavaScriptも知らなかった筆者がReact Nativeを学ぶ際に実際に引っかかった点を主に、「もっとこうやって教えてくれたら良かったのに」という視点から超初心者(かつての自分)向けにスクショ豊富で書いてます。わかりやすさを優先しているためざっくりとした説明が多く、本当の意味での正確性には欠けているかもしれません。ただ読み進めてコード書き写してくだけでそれなりのアプリが作れるようになる、そんな記事にしたいと思います。実際にあなたのスマホ上で作ったアプリを動かしますので、スマホからではなくPCからの閲覧推奨です。

作るアプリは"旅行記アプリ"です。過去に自分が行った国と日付を入力し、その時の思い出の写真とその旅行の3段階評価を付けて保存していく、というアプリです(Trip + record = Treco)。まあ使い道があるかどうかはいいとして(笑)、このアプリにはよくある要素の

・真ん中が `+` プラスボタンになってるタブ構成 & 画面遷移
・日付等を選ぶプルダウンメニュー
・地図や画像の配置

などが多く積み込まれています。あとはこれらの基礎要素を応用して組み替えたりしていけば、大体頭の中で想像しているアプリを実装できるようになりますし、何よりこのチュートリアルシリーズを読み終わってる頃には、自力で検索して必要な情報だけを探せれるようになっています。

前回記事では、旅の記録一覧がズラーっと並ぶホーム画面を実装しました。本記事では旅の記録を新規作成する機能を作成します。今まで同様スクショ&図解付きでわかりやすく説明していきます。

1. ウェルカム画面編
2. ホームタブ編
3. プラスボタン編 ← 今ココ

評価データをスマホから取り出す

今まで`HomeScreen`に表示させていた評価データは仮のデータ`allReviewsTmp`(TmpはTemporallyの略)をソースコード上にベタ書きしてました。このままではアプリ上からデータを追加したり削除したりといった操作ができないので、前回「ウェルカム画面を表示済みかどうか」をスマホ内に保存したのと同じように`AsyncStorage`に評価データを保存するように変えます。そのためにまずは先に「評価データを`AsyncStorage`から取り出す」ようにAction creatorを書き換えます。

`AsyncStorage`への書き込みや読み取りは非同期処理なので、Action creatorを非同期処理用に改造します。このために前回

・`redux-thunk`... ReduxでAsyncStorage等の非同期処理を扱うもの

を`$ npm`でインストールしたのです。ちょっとややこしいのですがAction creator内で非同期処理を行う場合、値自体を返すのではなく一旦無名の関数を返し、その無名関数が非同期処理をうまーく取り扱ってくれます(非同期処理が終わるまで待って、終わったら値を返してくれます)。では評価データを取り出すAction creatorである`fetchAllReviews`を非同期処理用に書き換えましょう。

export const fetchAllReviews = () => {
  return { type: FETCH_ALL_REVIEWS, payload: allReviewsTmp };
};

↓

export const fetchAllReviews = () => {
  // 一旦無名の関数を返す
  return async (dispatch) => {
     // `AsyncStorage`から評価データを読み取る(非同期処理)
    let stringifiedAllReviews = await AsyncStorage.getItem('allReviews');
    
    // 取り出した評価データをJavaScript用に変換
    let allReviews = JSON.parse(stringifiedAllReviews);

    // もし読み取った評価データがnullだったら
    if (allReviews == null) {
      // `AsyncStorage`に空の評価データを書き込む(非同期処理)
      allReviews = [];
      await AsyncStorage.setItem('allReviews', JSON.stringify(allReviews));
    }

    // 非同期処理が終わるまで待って終わったら値を返す
    dispatch({ type: FETCH_ALL_REVIEWS, payload: allReviews });
  };
};

actions/review_action.js

無名の関数に引数として渡された`dispatch`が「非同期処理が終わるまで待って終わったら値を返してくれる」をやってくれます。最後に`lodash`と`AsyncStorage`をインポートして、仮の評価データ`allReviewsTmp`を消してreview_action.jsは完成です!

import { AsyncStorage } from 'react-native'; // ←追記部分

import {
  FETCH_ALL_REVIEWS,
  SELECT_DETAIL_REVIEW,
} from './types';

export const fetchAllReviews = () => {
  // ゴニョゴニョ…
}

export const selectDetailReview = (selectedReview) => {
  return { type: SELECT_DETAIL_REVIEW, payload: selectedReview };
};

// 以下は消してOK
const GREAT = 'sentiment-very-satisfied';
const GOOD = 'sentiment-satisfied';
const POOR = 'sentiment-dissatisfied';

const allReviewsTmp = [
  :
  :

actions/review_action.js

では一旦ここで動作確認してみましょう。All, Great, Good, Poor のどれに切り替えても一つも評価データが表示されてませんがご安心ください、それで合ってます。

画像3

リセットボタンを作る

これから評価データは(アプリを再起動しても消えない)`AsyncStorage`にどんどん溜まっていくことになるので、`AsyncStorage`内の中身をリセットするボタンを`ProfileScreen`に作っておきます。またついでに評価データだけでなく、「ウェルカム画面を表示済みかどうか」の情報もリセットできるようにして、いつでもウェルカム画面を再表示できるようにもしておきましょう。

まず初めに`AsyncStorage`内の中身をリセットする関数`onResetButtonPress()`を`ProfileScreen`に作成します。この関数は`key`を引数に貰い、その`key`に対応する`AsyncStorage`内の中身を`removeItem(key)`でリセットします。その後データを消去したことをテンプレート文字列を用いて`Alert`で知らせます。

import React from 'react';
import { StyleSheet, Text, View, AsyncStorage, Alert } from 'react-native'; // ←追記部分
import { Button } from 'react-native-elements';


class ProfileScreen extends React.Component {
  onResetButtonPress = async (key) => { // ←追記部分
    // 'key'に対応するAsyncStorageの中身をリセット(非同期処理)
    await AsyncStorage.removeItem(key);

    Alert.alert(
      'Reset',
      `'${key}' in AsyncStorage has been removed.`,
      [
        { text: 'OK' },
      ],
      { cancelable: false }
    );
  }


  render() {
    // ゴニョゴニョ...
  }
}

screens/ProfileScreen.js

`AsyncStorage`と`Alert`のインポートをお忘れなく!ここでReduxを用いたちょっとしたトリックを入れます。確かに上記の関数は`AsyncStorage`内の中身を消去してくれるのですが、このままだとどうしても消したはずの評価データ達が`HomeScreen`画面上に残ってしまいます(データ自体はもう消えてはいるが、画面が更新されてないが故に残ったままのように見える)。そこでデータ削除後すぐに、データを取得するAction creatorである`fetchAllReviews()`を呼ぶとあら不思議、裏で`HomeScreen`が再描画されるので`HomeScreen`画面上にも消したはずのデータが残らなくなります!

import React from 'react';
import { StyleSheet, Text, View, AsyncStorage, Alert } from 'react-native'; // ←追記部分
import { Button } from 'react-native-elements';
import { connect } from 'react-redux'; // ←追記部分

import * as actions from '../actions'; // ←追記部分


class ProfileScreen extends React.Component {
  onResetButtonPress = async (key) => {
    // 'key'に対応するAsyncStorageの中身をリセット(非同期処理)
    await AsyncStorage.removeItem(key);

    // fetchAllReviews()を実行してHomeScreenを再描画させる
    // これしないと消したはずの評価データがHomeScreen画面上に残ってしまう
    if (key === 'allReviews') { // ←追記部分
      this.props.fetchAllReviews();
    }

    Alert.alert(
      'Reset',
      `'${key}' in AsyncStorage has been removed.`,
      [
        { text: 'OK' },
      ],
      { cancelable: false }
    );
  }


  render() {
    // ゴニョゴニョ...
  }
}


const mapStateToProps = (state) => { // ←追記部分
  return {
    allReviews: state.review.allReviews,
  };
};


export default connect(mapStateToProps, actions)(ProfileScreen); // ←追記部分

screens/ProfileScreen.js

またAction creatorを使用するので、それに合わせて`connect`、`actions`をインポートし、`mapStateToProps`関数と`connect`関数を末尾に追加します。

これで`AsyncStorage`内の中身をリセットする関数`onResetButtonPress()`の準備が整ったので、最後に`render()`関数内に実際にリセットボタンを追加していきましょう。

class ProfileScreen extends React.Component {
  onResetButtonPress = async (key) => {
    // ゴニョゴニョ...
  }


  render() {
    return (
      <View style={{ flex: 1, justifyContent: 'center' }}>
        <View style={{ padding: 20 }}> // ←追記部分
          <Button
            title="Go to Setting1Screen"
            onPress={() => this.props.navigation.navigate('setting1')}
          />
        </View>

        <View style={{ padding: 20 }}> // ←追記部分
          <Button // ←追記部分
            title="Reset welcome page"
            buttonStyle={{ backgroundColor: 'red' }}
            onPress={() => this.onResetButtonPress('isInitialized')}
          />
        </View>

        <View style={{ padding: 20 }}> // ←追記部分
          <Button // ←追記部分
            title="Reset all review data"
            buttonStyle={{ backgroundColor: 'red' }}
            onPress={() => this.onResetButtonPress('allReviews')}
          />
        </View>
      </View>
    );
  }

screens/ProfileScreen.js

buttonStyleプロパティにて`backgroundColor`を'red'にし、onPressプロパティにて`onResetButtonPress()`をアロー関数で呼び出します。注意点としては、`onResetButtonPress()`の引数には'シングルクォート'で囲った状態で渡してあげなければいけないという点です。

では動作確認として、「ウェルカム画面を表示済みかどうか」の情報である'isInitialized'をリセットしてみましょう。

画像4

Refreshすると再度ウェルカム画面が復活していますね。これでリセットボタンの作成は完成です!

評価データの追加機能を作る下準備

さていよいよ評価データを追加する画面`AddScreen`を作成していきます。まずは下準備として`this.state`の中身を明確化するために`INITIAL_STATE`と`constructor()`関数を追記します。

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


// 地図のズームサイズ
const MAP_ZOOM_RATE = 15.0; // ←追記部分

const INITIAL_STATE = { // ←追記部分
  // プルダウンメニューが開いてるか閉じてるか
  countryPickerVisible: false,
  dateFromPickerVisible: false,
  dateToPickerVisible: false,

  // プルダウンメニューで選択された日付データを保存
  chosenDateFrom: new Date().toLocaleString('ja'),
  chosenDateTo: new Date().toLocaleString('ja'),

  // 旅行の評価データ用
  tripDetail: {
    country: 'Select Counrty',
    dateFrom: 'From',
    dateTo: 'To',
    imageURIs: [
      require('../assets/add_image_placeholder.png'),
      require('../assets/add_image_placeholder.png'),
      require('../assets/add_image_placeholder.png'),
    ],
    rank: '',
  },

  // 地図描画用
  initialRegion: {
    latitude: 35.658581, // 東京タワー
    longitude: 139.745433, // 東京タワー
    latitudeDelta: MAP_ZOOM_RATE,
    longitudeDelta: MAP_ZOOM_RATE * 2.25
  },
};

class AddScreen extends React.Component {
  constructor(props) { // ←追記部分
    super(props);

    // `this.state`の中身を`INITIAL_STATE`で初期化
    this.state = INITIAL_STATE;
  }

  render() {
    return (
      // ゴニョゴニョ...
    );
  }
}

screens/AddScreen.js

`INITIAL_STATE`の各変数は、以下のような役割です。

// プルダウンメニューが開いてるか閉じてるか
・countryPickerVisible ... 国選択プルダウンメニューの開閉状態管理
・dateFromPickerVisible ... 出発日プルダウンメニューの開閉状態管理
・dateToPickerVisible ... 帰国日プルダウンメニューの開閉状態管理

// プルダウンメニューで選択された日付データを保存
・chosenDateFrom ... 出国日 ("2018/10/04 17:00:00"というフォーマット)
・chosenDateTo ... 帰国日 ("2018/10/04 17:00:00"というフォーマット)

※ JavaScriptで日付データは普通`new Date()`で十分ですが、これだと
"Thu Oct 04 2018 17:00:00 GMT+0900 (JST)"というフォーマットになってしまうので、"2018/10/04 17:00:00"というフォーマットに統一するためあえて
`new Date().toLocaleString('ja')`にしています。

// 旅行の評価データ用
・tripDetail
 ・country ... 国名
 ・dateFrom ... 出国日 ("2018/10/04"というフォーマット)
 ・dateTo... 帰国日 ("2018/10/04"というフォーマット)
 ・imageURIs ... 画像の保存場所 (配列)
 ・rank ... 評価ランク

// 地図描画用
・initialRegion
 ・latitude ... 緯度
 ・longitude ... 経度
 ・latitudeDelta ... 横方向の地図ズーム度合い
 ・longitudeDelta ... 縦方向の地図ズーム度合い

また後々使うので、評価ランクに関する定数(`GREAT`, `GOOD`, `POOR`等)とスマホ画面の横幅の定数(`SCREEN_WIDTH`)も同じ箇所に追記します。また、忘れずに`Dimensions`をインポートしてください。

import React from 'react';
import {
  StyleSheet, Text, View,
  Dimensions, // ←追記部分
} from 'react-native';
import { Icon } from 'react-native-elements';


// 評価ランクに関する定数
const GREAT = 'sentiment-very-satisfied'; // ←追記部分
const GREAT_COLOR = 'red'; // ←追記部分
const GOOD = 'sentiment-satisfied'; // ←追記部分
const GOOD_COLOR = 'orange'; // ←追記部分
const POOR = 'sentiment-dissatisfied'; // ←追記部分
const POOR_COLOR = 'blue'; // ←追記部分

// スマホ画面の横幅の定数
const SCREEN_WIDTH = Dimensions.get('window').width; // ←追記部分

// 地図のズームサイズ
const MAP_ZOOM_RATE = 15.0;

const INITIAL_STATE = {
  // ゴニョゴニョ...
}

screens/AddScreen.js

最後の下準備として綺麗なアニメーションを追加します。これがないと、プルダウンメニューを開いたり閉じたりするときにそっけない感じになってしまいます汗。

import React from 'react';
import {
  StyleSheet, Text, View,
  Dimensions, LayoutAnimation, UIManager, // ←追記部分
 } from 'react-native';
import { Icon } from 'react-native-elements';

    :
    :

class AddScreen extends React.Component {
  constructor(props) {
    // ゴニョゴニョ...
  }

  
  // 画面上で何か再描画される度に滑らかなアニメーションを適用する
  componentDidUpdate() { // ←追記部分
    UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true);
    LayoutAnimation.easeInEaseOut();
  }


  render() {
    // ゴニョゴニョ...
  }
}

screens/AddScreen.js

アニメーションは`componentDidUpdate()`関数の中で設定します。`componentDidUpdate()`関数は画面上で何か再描画される度に実行される関数で、その都度`LayoutAnimation.easeInEaseOut()`というアニメーションを施してくれます。`LayoutAnimation`は難しい設定なしにいい感じにアニメーションを設定してくれる優れモノで、`easeInEaseOut()`の他にも`LayoutAnimation.spring()`や`LayoutAnimation.linear()`などのアニメーションを設定することもできます。また、その上にある`UIManager.set〜〜`は魔法のおまじないだと思ってください笑。最後に`LayoutAnimation`と`UIManager`をインポートして、アニメーションの設定は完了です。

ヘッダーを自作しよう

次は`AddScreen`用の自作ヘッダーを作ります。`render()`関数内に既に記述されているTextタグとIconタグは消して、新たにHeaderタグを追記します。それに伴い、`StyleSheet.create()`関数でヘッダー用スタイル`headerStyle`も追記します。また、もう中央揃えでなくて良いので、一番外枠のViewタグのstyleプロパティから`justifyContent: 'center'`は消しました。

import React from 'react';
import {
  StyleSheet, Text, View,
  Dimensions, LayoutAnimation, UIManager,
 } from 'react-native';
import { Header, Icon } from 'react-native-elements'; // ←追記部分

    :
    :

class AddScreen extends React.Component {

    :
    :

  render() {
    return (
      <View style={{ flex: 1 }}> // ←`justifyContent: 'center'`を消した
        <Header // ←追記部分
          statusBarProps={{ barStyle: 'light-content' }} // ステータスバーの色
          backgroundColor="deepskyblue" // ヘッダーの色
          leftComponent={{ // 左上のアイコン
            icon: 'close',
            color: 'white',
            onPress: () => {
              // `this.state`を`INITIAL_STATE`にリセット
              this.setState({
                ...INITIAL_STATE, // `INITIAL_STATE`の中身をここに展開
                tripDetail: {
                  ...INITIAL_STATE.tripDetail, // `INITIAL_STATE.tripDetail`の中身をここに展開
                  imageURIs: [
                    require('../assets/add_image_placeholder.png'),
                    require('../assets/add_image_placeholder.png'),
                    require('../assets/add_image_placeholder.png'),
                  ]
                }
              });

              // HomeScreenに戻る
              this.props.navigation.navigate('home');
            }
          }}
          centerComponent={{ text: 'Add', style: styles.headerStyle }} // ヘッダータイトル
        />

      </View>
    );
  }
}


const styles = StyleSheet.create({ // ←追記部分
  headerStyle: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold'
  },
});

screens/AddScreen.js

ここで注意なのは、onPressプロパティの`this.setState()`関数です。ここではヘッダー左上のバツボタンを押されたら`this.state`を`INITIAL_STATE`にリセットしたいのですが、stateの旅行評価データ用のtripDetailオブジェクト内に配列(`imageURIs`)が入ってるというちょっと複雑な構造のため、単純に

this.setState({
  ...INITIAL_STATE // `INITIAL_STATE`の中身をここに展開
});

では済まないのです泣。ですのでわざわざ

this.setState({
  ...INITIAL_STATE, // `INITIAL_STATE`の中身をここに展開
  tripDetail: {
    ...INITIAL_STATE.tripDetail, // `INITIAL_STATE.tripDetail`の中身をここに展開
    imageURIs: [
      require('../assets/add_image_placeholder.png'),
      require('../assets/add_image_placeholder.png'),
      require('../assets/add_image_placeholder.png'),
    ]
  }
});

のように配列部分は別途指定してあげなければいけません。では一旦ここで動作確認してみましょう。

画像5

プラスボタンを押すとちゃんと水色のヘッダーが表示されていますね!左上のバツボタンを押すとHomeScreenに戻るはずです。

プルダウンメニューを作ろう

ヘッダーの次は、中身となるパーツ(コンポーネント)を作っていきましょう。プルダウンメニューは以下のような2段構成で作りたいと思います。

項目欄(常時表示) → ListItemタグ
  +
プルダウンメニュー本体(押されたら表示/非表示を切り替え) → Pickerタグ

項目欄を押すとプルダウンメニュー本体がヌルッと出てきて、もう一度項目欄を押すとヌルッと閉じるという感じです。このその都度ヌルッと動くのは、`componentDidUpdate()`関数で`LayoutAnimation.easeInEaseOut()`を設定したおかげです。では早速、国選択のプルダウンメニューから作ります。まず最初にHeaderタグ以下をScrollViewタグ('react-native'からインポート)で囲み、項目欄はListItemタグ('react-native-elements'からインポート)で作ります。またStyleSheetの中に`listItemStyle`オブジェクトも追加します。↓

import React from 'react';
import {
  StyleSheet, Text, View, ScrollView, // ←追記部分
  Dimensions, LayoutAnimation, UIManager,
 } from 'react-native';
import { Header, ListItem, Icon } from 'react-native-elements'; // ←追記部分

    :
    :

class AddScreen extends React.Component {

    :
    :

  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}> // ←追記部分
          <ListItem
            title="Country: "
            subtitle={
              <View style={styles.listItemStyle}>
                <Text
                  style={{
                    fontSize: 18,
                    // 現在の選択肢`this.state`が`INITIAL_STATE`のままなら灰色、それ以外の選択肢なら黒色
                    color: this.state.tripDetail.country === INITIAL_STATE.tripDetail.country ? 'gray' : 'black'
                  }}
                >
                  {this.state.tripDetail.country}
                </Text>
              </View>
            }
            // プルダウンメニューが開いてれば上矢印、閉じてれば下矢印
            rightIcon={{ name: this.state.countryPickerVisible === true ? 'keyboard-arrow-up' : 'keyboard-arrow-down' }}
            // 項目欄ListItemを押されたら、
            onPress={() => this.setState({
              countryPickerVisible: !this.state.countryPickerVisible, // 国選択のプルダウンメニューの開閉を切り替え
              dateFromPickerVisible: false, // 出国日選択のプルダウンメニューは閉じる
              dateToPickerVisible: false, // 帰国日選択のプルダウンメニューは閉じる
            })}
          />

        </ScrollView> // ←追記部分
      </View>
    );
  }
}


const styles = StyleSheet.create({
  headerStyle: {
    // ゴニョゴニョ...
  },
  listItemStyle: { // ←追記部分
    paddingTop: 5,
    paddingLeft: 20
  },
});

screens/AddScreen.js

・`this.state.tripDetail.country`...現在選択されている国名
・`this.state.countryPickerVisible`...国選択のプルダウンメニューの開閉状況

選択された国名は`this.state.tripDetail.country`に保存されるようになるので(次のステップで作ります)、subtitleプロパティで`this.state.tripDetail.country`を表示します。その際国名が初期データの`INITIAL_STATE.tripDetail.country`、つまり'Select country'のままでまだ国選択がされていない場合は、文字を灰色にします。また、プルダウンメニューが「今開いているか閉じているか」という状態を管理するのが`this.state.countryPickerVisible`という変数で、

`this.state.〇〇PickerVisible`が...
・true ならプルダウンメニューが開いてる
・false ならプルダウンメニューが閉じてる

ということにします。ですのでrightIconプロパティで「`this.state.countryPickerVisible`がtrueなら(開いてるなら)上矢印のアイコン('keyboard-arrow-up')、falseなら(閉じてるなら)下矢印のアイコン('keyboard-arrow-down')にする」という風にしています。ちなみに、

rightIcon={{ name: this.state.countryPickerVisible === true ? 'keyboard-arrow-up' : 'keyboard-arrow-down' }}

    ↓

rightIcon={{ name: this.state.countryPickerVisible ? 'keyboard-arrow-up' : 'keyboard-arrow-down' }}

のように " === true " の部分は省略しても大丈夫です。またonPressプロパティではアロー関数を用いて、

onPress={() => this.setState({
  countryPickerVisible: !this.state.countryPickerVisible, // 国選択のプルダウンメニューの開閉を切り替え
    :
    :
})}

のように`this.state.countryPickerVisible`のtrue/falseを切り替えています。ビックリマークはtrueとfalseを逆にする効果があるので、「現在の`this.state.countryPickerVisible`の逆を自分自身である`this.state.countryPickerVisible`にセットする」という手法でtrue/falseを切り替えを実現しています。念の為、他の`this.state.dateFromPickerVisible`と`this.state.dateToPickerVisible`は今trueだろうとfalseだろうと関係なしにfalseにセットします(プルダウンメニューを閉じる)。

では次にプルダウンメニュー本体を作ります。今回はPickerタグ('react-native'からインポート)を使用します。しかしただ単純に描画するだけではなく、「`this.state.countryPickerVisible`がtrueの時だけ描画する」というちょっとしたロジックが入るので、新たに`renderCountryPicker()`という自作関数を作って`render()`の外に出してスッキリさせます。

import React from 'react';
import {
  StyleSheet, Text, View, ScrollView, Picker, // ←追記部分
  Dimensions, LayoutAnimation, UIManager,
 } from 'react-native';
import { Header, ListItem, Icon } from 'react-native-elements';

    :
    :

class AddScreen extends React.Component {

    :
    :

  // 国選択のプルダウンメニューを描画
  renderCountryPicker() { // ←追記部分
    // もし国選択のプルダウンメニューの状態がtrueなら、
    if (this.state.countryPickerVisible === true) {
      // プルダウンメニューを描画
      return (
        <Picker
          // 現在の値がPicker内で最初から選択されてるようにする
          selectedValue={this.state.tripDetail.country}
          // Picker内で選択されてる値が変わったら、
          onValueChange={(itemValue) => {
            // `this.state.tripDetail.country`に引数の`itemValue`をセットする
            this.setState({
              ...this.state, // `this.state`の中身をここに展開
              tripDetail: {
                ...this.state.tripDetail, // `this.state.tripDetail`の中身をここに展開
                country: itemValue
              },
            });
          }}
        >
          <Picker.Item label={INITIAL_STATE.tripDetail.country} value={INITIAL_STATE.tripDetail.country} />
          <Picker.Item label="China" value="China" />
          <Picker.Item label="UK" value="UK" />
          <Picker.Item label="USA" value="USA" />
        </Picker>
      );
    }
  }


  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}>
          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderCountryPicker()} // ←追記部分

        </ScrollView>
      </View>
    );
  }
}

screens/AddScreen.js

`render()`の外に作った自作関数を呼び出すためには、

{this.〜〜()}

のように先頭に`this.`をつけなければいけません。呼び出す流れのイメージはこんな感じです。

画像6

Pickerタブは、

<Picker>
  <Picker.Item label="aaa" value="aaa" />
  <Picker.Item label="bbb" value="bbb" />
  <Picker.Item label="ccc" value="ccc" />
    :
    :
</Picker>

のように<Picker.Item>でどんどん選択肢を追加していきます。`label`と`value`は別々の値にしても良いですが、一緒の値にしとく方がわかりやすいです。今回の例では、

<Picker
  // ゴニョゴニョ...
>
  <Picker.Item label={INITIAL_STATE.tripDetail.country} value={INITIAL_STATE.tripDetail.country} />
  <Picker.Item label="China" value="China" />
  <Picker.Item label="UK" value="UK" />
  <Picker.Item label="USA" value="USA" />        
</Picker>

・`INITIAL_STATE.tripDetail.country` ("Select country")
・"China"
・"UK"
・"USA"

の4種類の選択肢を用意しているということになります。それでは動作確認してみましょう。

画像7

おお!ちゃんとプルダウンメニューから4つの選択肢が選べるようになってますね!またHeaderタグのonPressプロパティでちゃんと`this.state`を`INITIAL_STATE`にリセットしてるおかげで、例え国名を入力したままHomeScreenに戻ったとしても再度AddScreenに訪れた時にしっかりまた`INITIAL_STATE.tripDetail.country`つまり"Select country"に戻ってますね。

この調子で日付選択用のプルダウンメニューも作っちゃいましょう。作戦は同じく

項目欄(常時表示) → ListItemタグ
  +
プルダウンメニュー本体(押されたら表示/非表示を切り替え) → Pickerタグ

で、Pickerタグは外出しで専用の描画関数を自作します。項目欄のListItemは国選択の時とほぼ一緒です↓。

class AddScreen extends React.Component {

    :
    :

  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}> // ←追記部分
          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderCountryPicker()}

          <ListItem // ←追記部分
            title="Date: "
            subtitle={
              <View style={styles.listItemStyle}>
                <Text
                  style={{
                    fontSize: 18,
                    // 現在の選択肢`this.state`が`INITIAL_STATE`のままなら灰色、それ以外の選択肢なら黒色
                    color: this.state.tripDetail.dateFrom === INITIAL_STATE.tripDetail.dateFrom ? 'gray' : 'black'
                  }}
                >
                  {this.state.tripDetail.dateFrom}
                </Text>
              </View>
            }
            // プルダウンメニューが開いてれば上矢印、閉じてれば下矢印
            rightIcon={{ name: this.state.dateFromPickerVisible ? 'keyboard-arrow-up' : 'keyboard-arrow-down' }}
            // 項目欄ListItemを押されたら、
            onPress={() => this.setState({
              countryPickerVisible: false, // 国選択のプルダウンメニューは閉じる
              dateFromPickerVisible: !this.state.dateFromPickerVisible, // 出国日選択のプルダウンメニューの開閉を切り替え
              dateToPickerVisible: false, // 帰国日選択のプルダウンメニューは閉じる
            })}
          />

        </ScrollView>
      </View>
    );
  }
}

screens/AddScreen.js

日付選択のListItemはあまり国選択のListItemと変わらないですが、Pickerはちょっと違って日付選択用のPickerがあるのでそちらを使います。また日付選択用のPickerはちょっと複雑で、iOS用とAndroid用で共通ではないので別々に作ります。まずはターミナルでAndroid用の日付選択Pickerを`$ npm`します。

$ npm install react-native-datepicker
$ npm install

iOS用の日付選択PickerはDatePickerIOSタグが'react-native'に用意されてるので、外部から`$ npm`しなくても大丈夫です。ではiOSの時とAndroidの時でswitch文で場合分けして書いていきましょう↓。

import React from 'react';
import {
  StyleSheet, Text, View, ScrollView, Picker, DatePickerIOS, // ←追記部分
  Dimensions, LayoutAnimation, UIManager, Platform, // ←追記部分
 } from 'react-native';
import { Header, ListItem, Icon } from 'react-native-elements';
import DatePicker from 'react-native-datepicker'; // ←追記部分

    :
    :

class AddScreen extends React.Component {

    :
    :

  // 出国日のプルダウンメニューを描画
  renderDateFromPicker() { // ←追記部分
    if (this.state.dateFromPickerVisible) {
      switch (Platform.OS) {
        // iOSだったら、
        case 'ios':
          return (
            <DatePickerIOS
              mode="date"
              date={new Date(this.state.chosenDateFrom)}
              onDateChange={(date) => {
                // `date` = "Thu Oct 04 2018 17:00:00 GMT+0900 (JST)"

                // "Thu Oct 04 2018 17:00:00 GMT+0900 (JST)" ---> "2018/10/04 17:00:00"
                const dateString = date.toLocaleString('ja');

                this.setState({
                  tripDetail: {
                    ...this.state.tripDetail,
                    dateFrom: dateString.split(' ')[0] // "2018/10/04 17:00:00" ---> "2018/10/04"
                  },
                  chosenDateFrom: dateString,
                  chosenDateTo: dateString, // 帰国日の初期選択日付を出国日にセットする
                });
              }}
            />
          );

        // Androidだったら、
        case 'android':
          return (
            <DatePicker
              mode="date"
              date={new Date(this.state.chosenDateFrom)}
              format="YYYY-MM-DD"
              confirmBtnText="OK"
              cancelBtnText="キャンセル"
              onDateChange={(date) => {
                // `date` = "2018-10-04 17:00"

                // "2018-10-04 17:00" ---> "2018-10-04 17:00:00"
                let dateString = `${date}:00`;

                // "2018-10-04 17:00:00" ---> "2018/10/04 17:00:00"
                dateString = dateString.replace(/-/g, '/');

                this.setState({
                  tripDetail: {
                    ...this.state.tripDetail,
                    dateFrom: dateString.split(' ')[0] // "2018/10/04 17:00:00" ---> "2018/10/04"
                  },
                  chosenDateFrom: dateString,
                  chosenDateTo: dateString, // 帰国日の初期選択日付を出国日にセットする
                });
              }}
            />
          );

        // iOSでもAndroidでもなかったら、
        default:
          // 何も描画しない
          return <View />;
      }
    }
  }


  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}>
          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderCountryPicker()}

          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderDateFromPicker()} // ←追記部分

        </ScrollView>
      </View>
    );
  }
}

screens/AddScreen.js

【共通プロパティ】
・mode ... "date" / "time" / "datetime" のいずれか
・date ... プルダウンメニューを開いた時に最初に選択されてる日付

【DatePickerIOSプロパティ】
・onDateChange ... 引数の`date`のフォーマットは"Thu Oct 04 2018 17:00:00 GMT+0900 (JST)"

【DatePickerプロパティ】
・format ... 日付のフォーマット
・confirmBtnText ... 確認ボタンのテキスト
・cancelBtnText ... キャンセルボタンのテキスト
・onDateChange ... 引数の`date`のフォーマットは"2018-10-04 17:00"

ややこしいのは、DatePickerIOSタグとDatePickerタグでonDateChangeプロパティの引数の`date`のフォーマットが違うというところで、かつどちらのフォーマットもアプリ画面上に表示したい日付フォーマットとはちょっと違うというところです。なので

「DatePickerIOSタグとDatePickerタグの引数の`date`のフォーマットを"2018/10/04 17:00:00"に揃える」
  ↓
「"2018/10/04 17:00:00"に揃えたらその前半部分の"2018/10/04"だけ取り出してアプリ画面上に表示する」

という流れを取っています。また`this.state`には実際にアプリ画面上に表示する日付の`dateFrom`と、日付データ自体である`chosenDateFrom`の2つに分けて保存しており、

・`dateFrom` ... "2018/10/04" (アプリ画面上に表示用)
・`chosenDateFrom`... "2018/10/04 17:00:00" (データとして保存用)

としています。また`chosenDateFrom`にも"2018/10/04 17:00:00"というフォーマットで日付データを保存していますが、これは次に作る帰国日のプルダウンメニューを開いたときに、最初から出国日が選択されてるようにするためです。

"2018/10/04 17:00:00"から半角スペースで区切られた前半部分の"2018/10/04"を取り出すのには、`split()`関数を用いています。`split()`関数は、対象の文字列を指定された文字(今回の例では半角スペース)で区切ってそれらを配列にして返してくれるという機能を持ってます。

const mojiretsu = "aaa,bbb,ccc";
const hairetsu = mojiretsu.split(',');

// hairetsu[0] は "aaa"
// hairetsu[1] は "bbb"
// hairetsu[2] は "ccc"

ですので`dateString.split(' ')[0]`とすれば、dateString = "2018/10/04 17:00:00"から半角スペースで区切られた前半部分の"2018/10/04"を取り出すことができます。ではここで動作確認してみましょう。

画像8

ちゃんと日付が選択できてますね!帰国日を選択するプルダウンメニューも同じ要領で作れちゃいます。ただ違うところは、DatePickerIOSタグにはminimumDateプロパティが、DatePickerタグにはminDateプロパティが追加されることです。これは選択できる日付の下限を決めるプロパティで、帰国日は出国日よりも必ず後の日付になるはずなので、このような仕様にしました。また、ListItemタグのtitleプロパティが空欄("")になってます。

では項目欄のListItemタグとプルダウンメニュー描画用の`renderDateToPicker()`関数の両方一気にいっちゃいましょう↓。

class AddScreen extends React.Component {

    :
    :

  // 帰国日のプルダウンメニューを描画
  renderDateToPicker() { // ←追記部分
    if (this.state.dateToPickerVisible) {
      switch (Platform.OS) {
        // iOSだったら、
        case 'ios':
          return (
            <DatePickerIOS
              mode="date"
              minimumDate={new Date(this.state.chosenDateFrom)} // ←変更点!
              date={new Date(this.state.chosenDateTo)}
              onDateChange={(date) => {
                // `date` = "Thu Oct 04 2018 17:00:00 GMT+0900 (JST)"

                // "Thu Oct 04 2018 17:00:00 GMT+0900 (JST)" ---> "2018/10/04 17:00:00"
                const dateString = date.toLocaleString('ja');

                this.setState({
                  tripDetail: {
                    ...this.state.tripDetail,
                    dateTo: dateString.split(' ')[0] // "2018/10/04 17:00:00" ---> "2018/10/04"
                  },
                  chosenDateTo: dateString,
                });
              }}
            />
          );

        // Androidだったら、
        case 'android':
          return (
            <DatePicker
              mode="date"
              minDate={new Date(this.state.chosenDateFrom)} // ←変更点!
              date={new Date(this.state.chosenDateTo)}
              format="YYYY-MM-DD"
              confirmBtnText="OK"
              cancelBtnText="キャンセル"
              onDateChange={(date) => {
                // `date` = "2018-10-04 17:00"

                // "2018-10-04 17:00" ---> "2018-10-04 17:00:00"
                let dateString = `${date}:00`;

                // "2018-10-04 17:00:00" ---> "2018/10/04 17:00:00"
                dateString = dateString.replace(/-/g, '/');

                this.setState({
                  tripDetail: {
                    ...this.state.tripDetail,
                    dateTo: dateString.split(' ')[0] // "2018/10/04 17:00:00" ---> "2018/10/04"
                  },
                  chosenDateTo: dateString,
                });
              }}
            />
          );

        // iOSでもAndroidでもなかったら、
        default:
          // 何も描画しない
          return <View />;
      }
    }
  }


  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}>
          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderCountryPicker()}

          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderDateFromPicker()}

          <ListItem // ←追記部分
            title="" // ←変更点!
            subtitle={
              <View style={styles.listItemStyle}>
                <Text
                  style={{
                    fontSize: 18,
                    // 現在の選択肢`this.state`が`INITIAL_STATE`のままなら灰色、それ以外の選択肢なら黒色
                    color: this.state.tripDetail.dateTo === INITIAL_STATE.tripDetail.dateTo ? 'gray' : 'black'
                  }}
                >
                  {this.state.tripDetail.dateTo}
                </Text>
              </View>
            }
            // プルダウンメニューが開いてれば上矢印、閉じてれば下矢印
            rightIcon={{ name: this.state.dateToPickerVisible ? 'keyboard-arrow-up' : 'keyboard-arrow-down' }}
            // 項目欄ListItemを押されたら、
            onPress={() => this.setState({
              countryPickerVisible: false, // 国選択のプルダウンメニューは閉じる
              dateFromPickerVisible: false, // 出国日選択のプルダウンメニューは閉じる
              dateToPickerVisible: !this.state.dateToPickerVisible, // 帰国日日選択のプルダウンメニューの開閉を切り替え
            })}
          />

          {this.renderDateToPicker()} // ←追記部分

        </ScrollView>
      </View>
    );
  }
}

screens/AddScreen.js

項目欄のListItemタグとプルダウンメニュー描画用の`renderDateToPicker()`関数の両方を書き終えたら、動作確認してみましょう。

画像9

ちゃんと出国日に選択した日付が最初から選択されてますね!そして出国日以前の日付を選択しようとしても、戻されます笑。これらの仕様はminimumDate / minDateプロパティを設定したおかげです。これでプルダウンメニューは全部完成しました!

地図を表示しよう

プルダウンメニューで国が選択されたら、その国の地図が自動で表示されるようにします。その為にまずは国選択用のプルダウンメニューを描画する`renderCountryPicker()`関数のonValueChangeプロパティを少しいじります。

import Geocoder from 'react-native-geocoding'; // ←追記部分
import React from 'react';
import {
  StyleSheet, Text, View, ScrollView, Picker, DatePickerIOS,
  Dimensions, LayoutAnimation, UIManager, Platform,
 } from 'react-native';
import { Header, ListItem, Icon } from 'react-native-elements';
import DatePicker from 'react-native-datepicker'; 

    :
    :

class AddScreen extends React.Component {
  constructor(props) {
    // ゴニョゴニョ...
  }

  // 画面上で何か再描画される度に滑らかなアニメーションを適用する
  componentDidUpdate() {
    // ゴニョゴニョ...
  }

  // 国選択のプルダウンメニューを描画
  renderCountryPicker() {
    // もし国選択のプルダウンメニューがtrueなら、
    if (this.state.countryPickerVisible === true) {
      // プルダウンメニューを描画
      return (
        <Picker
          // 現在の値がPicker内で最初から選択されてるようにする
          selectedValue={this.state.tripDetail.country}
          // Picker内で選択されてる値が変わったら、
          onValueChange={async (itemValue) => {
            // Google map APIキーをセットする
            Geocoder.setApiKey(' /* YOUR_GOOGLE_MAP_API_KEY */ '); // ←追記部分

            // 国名から緯度経度を取得する
            let result = await Geocoder.getFromLocation(itemValue); // ←追記部分

            // `this.state.tripDetail.country`に引数の`itemValue`をセットする
            // `this.state.initialRegion`に緯度経度と地図のズーム度合いをセットする
            this.setState({
              ...this.state,
              tripDetail: {
                ...this.state.tripDetail,
                country: itemValue
              },
              initialRegion: { // ←追記部分
                latitude: result.results[0].geometry.location.lat,
                longitude: result.results[0].geometry.location.lng,
                latitudeDelta: MAP_ZOOM_RATE,
                longitudeDelta: MAP_ZOOM_RATE * 2.25
              }
            });

          }}
        >
          <Picker.Item label={INITIAL_STATE.tripDetail.country} value={INITIAL_STATE.tripDetail.country} />
          <Picker.Item label="China" value="China" />
          <Picker.Item label="UK" value="UK" />
          <Picker.Item label="USA" value="USA" />
        </Picker>
      );
    }
  }

  // 出国日のプルダウンメニューを描画
  renderDateFromPicker() {
    // ゴニョゴニョ...
  }

  // 帰国日のプルダウンメニューを描画
  renderDateToPicker() {
    // ゴニョゴニョ...
  }

  render() {
    // ゴニョゴニョ...
  }
}

screens/AddScreen.js

前回DetailScreen.jsの`componentDidMount()`関数で使用したのと同じく、`Geocoder`を`react-native-geocoding`からインポートしてGoogle map APIキーをセットし、`getFromLocation()`関数を用いて国名から→緯度経度へ変換します。ここで`getFromLocation()`関数は非同期処理ですので、文頭には`await`を、関数の最初には「非同期処理がありますよ」を示す`async`を付けます。最後に`this.state`に取得した緯度経度の情報と、地図を表示する際のズーム度合いをセットします。

次は`this.state`にセットされた緯度経度の地図を描画する`renderMap()`関数を作ります。まず`MapView`を`expo`からインポートします。

import Geocoder from 'react-native-geocoding';
import React from 'react';
import {
  StyleSheet, Text, View, ScrollView, Picker, DatePickerIOS,
  Dimensions, LayoutAnimation, UIManager, Platform,
 } from 'react-native';
import { Header, ListItem, Icon } from 'react-native-elements';
import DatePicker from 'react-native-datepicker';
import { MapView } from 'expo'; // ←追記部分

    :
    :

class AddScreen extends React.Component {

    :
    :

  // 選択された国の地図を描画
  renderMap() { // ←追記部分
    // 国が選択されたとき(国名が`INITIAL_STATE`じゃないとき)かつ
    // 国選択プルダウンメニューが閉じられたら、
    if (
      this.state.tripDetail.country !== INITIAL_STATE.tripDetail.country &&
      this.state.countryPickerVisible === false
    ) {
      // 地図を描画する
      return (
        <MapView
          style={{ height: SCREEN_WIDTH }}
          scrollEnabled={false}
          cacheEnabled={Platform.OS === 'android'}
          initialRegion={this.state.initialRegion}
        />
      );
    }
  }


  render() {
    return (
      <View style={{ flex: 1 }}>
        <Header
          // ゴニョゴニョ...
        />

        <ScrollView style={{ flex: 1 }}>
          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderCountryPicker()}

          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderDateFromPicker()}

          <ListItem
            // ゴニョゴニョ...
          />

          {this.renderDateToPicker()}

          {this.renderMap()} // ←追記部分

        </ScrollView>
      </View>
    );
  }
}

screens/AddScreen.js

`renderDateToPicker()`関数の直下に`renderMap()`関数を追記し、`renderMap()`関数本体の中身は「国が選択されたとき(国名が`INITIAL_STATE`じゃないとき)かつ国選択プルダウンメニューが閉じられたら→地図を描画する」という風なロジックを組みます。またMapViewタグは前回のDetailScreen.jsと同じく

・`style`...装飾関係
・`scrollEnabled`...地図上をスクロールできるかどうか
・`cacheEnabled`...キャッシュするかどうか
・`initialRegion`...最初に描画する土地の情報

の4つのプロパティを使用します。では動作確認してみましょう。

画像10

プルダウンメニューを閉じると選択された国の地図が表示されていますね!完成まであと3つの関数です!!

カメラロールから画像を選択しよう

ここから先は

16,757字 / 5画像

¥ 399

期間限定!PayPayで支払うと抽選でお得

チュートリアル完成しましたらTwitterでのご報告お待ちしております!笑