ReactNativeで基本的なフォームを作成する（Formik/Yupを使用）

React Nativeで基本的なフォーム画面を作成する手法を模索したのでまとめてみます。ラジオボタンやドロップダウン選択など、アプリにあまり無いようなものも使ってフォームを作成する際、悩ましいのがこういう点だと思います。

* UIコンポーネント類はいくつかあるが、色々な種類のフォームフィールドを扱うためのスタンダードなライブラリが無い
* それらをつぎはぎするとデザインが統一しにくくなる
* デザインのカスタマイズをある程度できるようにしたい
* そもそもチェックボックスやラジオボタンなどにライブラリを使う必要を感じない（でもなるべく煩雑にならないようにしたい）
* Formik等にstate管理を任せてYupでバリデーションしたい、でもFormikとReact Nativeの相性があまりよくない

これらをなるべく解決したいと思います。
デザインのカスタマイズ性と既成のUIコンポーネント類を使う手軽さとどちらを取るかは人によって考え方が異なると思いますが、使いたい全ての機能をサポートするような既成のものがなかったため、この記事では自作している部分が多くなりました。

## 対応するフォーム用コンポーネント

* テキスト入力
* ラジオボタン
* チェックボックス
* トグルスイッチ
* ドロップダウン
* ファイル入力
* 日付選択

## Formik / Yup を導入

最終的なソースを最後に載せますが、順を追って要素を追加していきます。

まずは[Formik](https://jaredpalmer.com/formik/)と[Yup](https://github.com/jquense/yup)を使って全体の骨組みを作ります。

```jsx:App.js
import React, { Component } from "react";
import {
  StyleSheet,
  Text,
  View,
  ScrollView,
  TextInput
} from "react-native";
import { Formik } from "formik";
import * as Yup from "yup";

const schema = Yup.object().shape({
  name: Yup.string()
    .min(3, "3文字以上で入力してください")
    .max(20, "20文字以内で入力してください")
    .required("氏名を入力してください")
});

const styles = StyleSheet.create({
  container: {
    width: "100%",
    padding: 24,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center"
  },
  form: {
    width: "100%"
  }
});

export default class App extends Component {
  onSubmit = async (values, actions) => {
    // データ送信
  };

  render() {
    return (
      <ScrollView contentContainerStyle={styles.container}>
        <View style={styles.form}>
          <Formik
            initialValues={{
              name: ""
            }}
            validateOnMount
            validationSchema={schema}
            onSubmit={this.onSubmit}
          >
            {({ handleSubmit, handleChange, handleBlur, isValid, isSubmitting, values, errors, touched }) => (
              <>
                <View>
                  {errors.name && touched.name ? <Text>{errors.name}</Text> : null}
                  <TextInput
                    value={values.name}
                    onChangeText={handleChange('name')}
                    onBlur={handleBlur('name')}
                    placeholder="氏名を入力してください"
                  />
                </View>
                <Button
                  title="Submit"
                  onPress={handleSubmit}
                  disabled={!isValid || isSubmitting}
                />
              </>
            )}
          </Formik>
        </View>
      </ScrollView>
    );
  }
}
```

大まかな流れとしてはHTMLと一緒ですが、HTMLでFormikを使用する場合は`<Form />`や`<Field />`などのコンポーネントをそのまま使って簡潔に書けるのに対して、React NativeでFormikを使う際は一手間必要です。
エラーの表示や入力イベントのハンドリングなどを`<Formik />`の子要素として渡すfunctionの引数（`errors`, `handleChange`等）から自分で処理します。

また、Formikはテキスト入力以外のフィールドからは通常HTMLのイベントを利用して値などを取得しているため、React Nativeで文字列以外の値を持つ入力フィールドを普通に作ろうとすると以下のようなエラーが出ると思います。

```
TypeError: undefined is not an object (evaluating 'target.type')
```

そこで、これらを解決するためというのと、複数種類の入力フィールドのデザインを統一するために、各フィールドをラップするコンポーネントを作ってみます。

## FormFieldコンポーネントを作成

まずはHTMLに依存している部分を避けてFormikをReact Nativeで使うために、`react-native-formik`というヘルパーをインストールし、`withFormikControl`関数を使ってみます。

これは引数に与えられたコンポーネントのpropsにFormikの状態やセッター関数を渡してくれる高階functional componentだと認識しておけば大丈夫です。ちなみに実装自体はシンプルなのですが、[npm](https://www.npmjs.com/package/react-native-formik)で`Unpacked Size`が2.4 MBとあったので何事かと思ったら、サンプル画像の容量が大きいだけでした。

さて、この`withFormikControl`を使ってコンポーネントをラップしますが、せっかくなので下記のように、フィールドのタイトルやエラーの処理、デザインなどを統一するためのさらに高階のコンポーネント（`FormField`）を作っておきます。

```jsx:FormField.js
import React from "react";
import * as PropTypes from "prop-types";
import { View, Text, StyleSheet } from "react-native";
import { withFormikControl } from "react-native-formik";
import colors from "../constants/colors";

const styles = StyleSheet.create({
  container: {
    width: "100%",
    marginVertical: 12,
    paddingBottom: 24
  },
  label: {
    fontSize: 16,
    marginBottom: 10
  },
  error: {
    position: "absolute",
    color: colors.red,
    fontSize: 12,
    bottom: 0
  }
});

function FormField(WrappedComponent) {
  return withFormikControl(function(props) {
    const { label, error, touched } = props;
    return (
      <View style={styles.container}>
        <Text style={styles.label}>{label}</Text>
        <WrappedComponent {...props} />
        {error && touched ? <Text style={styles.error}>{error}</Text> : null}
      </View>
    );
  });
}

FormField.propTypes = {
  label: PropTypes.string.isRequired,
  name: PropTypes.string.isRequired
};

export default FormField;
```

`FormField > withFormikControl > 子要素(WrappedComponent)`という構造になっているのがわかるでしょうか。
バリデーションエラーはフィールドを触っていなければ表示しないようにするために、`touched`も見て表示を切り替えています。
また、スタイリングの話ですが、エラー表示によって下の要素がガタッとずれないように`position: "absolute"`や`padding`によって調整しています。

ちょっと分かりにくいかもしれないので、一番シンプルなテキスト入力フィールドの例を先に載せます。

## テキスト入力コンポーネントを作成

```jsx:Input.js
import React from "react";
import * as PropTypes from "prop-types";
import { TextInput } from "react-native";
import colors from "../constants/colors";
import FormField from "./FormField";

const style = {
  width: "100%",
  height: 50,
  paddingHorizontal: 10,
  borderWidth: 1,
  borderColor: colors.border,
  borderRadius: 4,
  fontSize: 16
};

function Input(props) {
  const { placeholder, value, setFieldValue, setFieldTouched } = props;
  return (
    <TextInput
      style={style}
      placeholder={placeholder}
      autoCapitalize="none"
      onChangeText={setFieldValue}
      onBlur={() => setFieldTouched(true)}
      value={value}
    />
  );
}

Input.defaultProps = {
  placeholder: "",
  value: ""
};

Input.propTypes = {
  placeholder: PropTypes.string,
  value: PropTypes.string,
  setFieldValue: PropTypes.func.isRequired,
  setFieldTouched: PropTypes.func.isRequired
};

export default FormField(Input);
```

フォーム内に追加するときはこのような感じになります。

```jsx:App.js
...
{({ handleSubmit, isValid, isSubmitting }) => (
  <>
    <Input
      label="氏名"
      name="name"
      placeholder="氏名を入力してください"
    />
...
```

`Input.js`自体は普通っぽいですが、最後の行で`FormField`関数によってコンポーネントをラップしています。
`App.js`の例を見て分かる通り、この`Input`コンポーネント自体に明示的に渡す`props`は`label`と`name`、`placeholder`のみ（もちろん、`style`や`disabled`など適宜追加してOKですし、propsをそのまま全てdeconstructionして渡すのでもOK）です。
`label`は`FormField`関数内でタイトルとして表示されるために利用され、`name`は`withFormikControl`を通してFormikにおいてどのフィールドの要素なのかを特定するために使われます。そして、`placeholder`は最終的に`Input.js`で定義した子要素に伝わります。
`Input.js`でJSXを返している部分を見てみると、`props`に`value`, `setFieldValue`, `setFieldTouched`といった値が入っていて、それを使って`value`や`touched`の変更していることが分かるかと思います。これらの値は`withFormikControl`を通したことで追加されたものです。

さて、これでFormikをReact Nativeで扱うときの問題はスッキリ解決しそうです。
タイトルやエラー表示を共通化しつつ、スタイリング自由なテキスト入力フィールドができました。

<img width="375" alt="PNGイメージ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/58c79c86-fca2-6c92-ab29-d852fe3fa331.png">

ここで一旦ちょっと脇道にそれる気がしますが、入力中にキーボードが表示されている時に、ビューの他の部分をタップしたらキーボードを閉じる処理を追加します。これはHTMLと違って自分で処理しなければいけません。

ページ全体を`TouchableWithoutFeedback`で囲み、`onPress`（あるいは`onPressIn`でも)で`Keyboard.dismiss`メソッドを呼ぶようにしておきます。

```jsx:App.js
import {
...
  TouchableWithoutFeedback,
  Keyboard,
...
} from "react-native";

...
      <ScrollView contentContainerStyle={styles.container}>
        <TouchableWithoutFeedback onPress={Keyboard.dismiss}> // ここ
          <View style={styles.form}> // 以下Formik
...
```

## ラジオボタンを作成

さて、ここからは単純にそれぞれのコンポーネントを作っていきます。
あとあとデザインの変更やアニメーションの調整など容易にするために、(使えるライブラリは使いつつも)あまり大したことをしていないUIコンポーネント類はなるべく使わないという方向でいきます。

```jsx:RadioButton.js
import React from "react";
import * as PropTypes from "prop-types";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import colors from "../constants/colors";
import FormField from "./FormField";

const styles = StyleSheet.create({
  container: {
    flexDirection: "row"
  },
  option: {
    padding: 10,
    marginLeft: -10,
    marginRight: 30,
    flexDirection: "row",
    alignItems: "center"
  },
  label: {
    fontSize: 16
  },
  labelActive: {
    color: colors.accent
  },
  icon: {
    width: 20,
    height: 20,
    marginLeft: 10,
    borderWidth: 1,
    borderRadius: 10,
    borderColor: colors.border,
    justifyContent: "center",
    alignItems: "center"
  },
  iconActive: {
    borderColor: colors.accent
  },
  iconInner: {
    width: 10,
    height: 10,
    backgroundColor: colors.accent,
    borderRadius: 5
  }
});

function RadioButton(props) {
  const { options, value, setFieldValue } = props;
  return (
    <View style={styles.container}>
      {options.map(option => {
        const active = option.value === value;
        return (
          <TouchableOpacity
            disabled={active}
            key={option.value}
            onPress={() => {
              setFieldTouched(true);
              setFieldValue(option.value);
            }}
          >
            <View style={[styles.option, active && styles.active]}>
              <Text style={[styles.label, active && styles.labelActive]}>
                {option.label}
              </Text>
              <View style={[styles.icon, active && styles.iconActive]}>
                {active && <View style={styles.iconInner} />}
              </View>
            </View>
          </TouchableOpacity>
        );
      })}
    </View>
  );
}

RadioButton.defaultProps = {
  value: null
};

RadioButton.propTypes = {
  value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  options: PropTypes.arrayOf(
    PropTypes.shape({
      label: PropTypes.string.isRequired,
      value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
        .isRequired
    })
  ).isRequired,
  setFieldValue: PropTypes.func.isRequired,
  setFieldTouched: PropTypes.func.isRequired
};

export default FormField(RadioButton);
```

ラジオボタンは、表示名と値を持つ選択肢の配列を受け取り現在の値によって表示を切り替える単純な要素です。
Formikと連携するため、何かしら選択したときに`setFieldTouched`に`true`を渡してFormikの`touched`を変更します。これらの処理は既成のUIコンポーネントを使用しても構造的に同じになります。

```jsx:App.js
// 選択肢の定義
const genderOptions = [
  { label: "男性", value: 0 },
  { label: "女性", value: 1 }
];

// Yupのスキーマを定義（値は数値にしてみます。他の値を取らないよう制限も追加。）
const schema = Yup.object().shape({
  ...
  gender: Yup.number()
    .oneOf(
      genderOptions.map(option => option.value),
      "性別を選択して下さい"
    )
    .required("性別を選択して下さい"),
  ...
});

// フォーム内に追加
<RadioButton
  label="性別"
  name="gender"
  options={genderOptions}
/>
```

これでシンプルなラジオボタンができました。

<img width="375" alt="IMG_0238.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/2496df36-15da-6d8b-32a0-8c6de63fd7ef.jpeg">

## チェックボタンを作成

```jsx:CheckBox.js
import React from "react";
import * as PropTypes from "prop-types";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import Icon from "@expo/vector-icons/Ionicons";
import colors from "../constants/colors";
import FormField from "./FormField";

const styles = StyleSheet.create({
  container: {
    padding: 10,
    marginLeft: -10,
    marginTop: -10,
    marginBottom: -10,
    marginRight: -10,
    flexDirection: "row",
    alignItems: "center"
  },
  box: {
    width: 30,
    height: 30,
    marginRight: 10,
    borderWidth: 1,
    borderRadius: 4,
    borderColor: colors.border,
    justifyContent: "center",
    alignItems: "center"
  },
  checked: {
    backgroundColor: colors.accent,
    borderColor: colors.accent
  },
  checkedInner: {
    width: 30,
    height: 30,
    textAlign: "center"
  }
});

function CheckBox(props) {
  const { title, value, setFieldValue, setFieldTouched } = props;
  return (
    <TouchableOpacity
      onPress={() => {
        setFieldTouched(true);
        setFieldValue(!value);
      }}
    >
      <View style={styles.container}>
        <View style={[styles.box, value && styles.checked]}>
          {value && (
            <Icon
              name="ios-checkmark"
              size={30}
              color={colors.white}
              style={styles.checkedInner}
            />
          )}
        </View>
        <Text style={styles.title}>{title}</Text>
      </View>
    </TouchableOpacity>
  );
}

CheckBox.propTypes = {
  title: PropTypes.string.isRequired
};

export default FormField(CheckBox);
```

こちらも非常にシンプルに、真偽値に応じて見た目を変えればOKです。

上記のラジオボタンの場合は一回タップしたら何かしら選択せざるをえないためrequiredエラーが発生することはありませんが、こちらのチェックボックスの場合はYupでチェックを必要とするようにしてみます。

```jsx:App.js
const schema = Yup.object().shape({
  ...
  terms: Yup.bool()
    .oneOf([true], "同意が必要です"),
  ...
});

<CheckBox label="同意事項" title="同意する" name="terms" />
```

チェックした状態から
<img width="375" alt="PNGイメージ 6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0d3faeb4-2cc4-5bf5-46f6-afed42bc74f9.png">

チェックを外すと、`touched`がtrueになっているのでエラー文言が表示されます。（`FormField.js`で実装した部分）
<img width="375" alt="PNGイメージ 7.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/98893614-42f6-ca7a-41ca-ba2bee683ba0.png">

## トグルスイッチ（Switch）を作成

トグルスイッチは各OS標準のものをそのまま使ってみます。

```jsx:Switch.js
import React from "react";
import { Switch as RNSwitch, View, StyleSheet } from "react-native";
import FormField from "./FormField";
import colors from "../constants/colors";

const styles = StyleSheet.create({
  container: {
    alignItems: "flex-start"
  }
});

function Switch(props) {
  const { value, setFieldValue, setFieldTouched } = props;
  return (
    <View style={styles.container}>
      <RNSwitch
        value={value}
        ios_backgroundColor={colors.lightGray}
        thumbColor={colors.white}
        trackColor={{ true: colors.accent, false: colors.lightGray }}
        onValueChange={newValue => {
          setFieldTouched(true);
          setFieldValue(newValue);
        }}
      />
    </View>
  );
}

export default FormField(Switch);
```

処理の構造はチェックボックスと同様になります。
カスタマイズできるのは色のみですが、OS標準とあっておかしなところが無いので使いやすそうです。
各OS見た目が違い、このような感じになります。

iOS
<img width="375" alt="スクリーンショット 2019-12-21 23.05.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/c4ccab78-ee4c-101e-0784-d218da237120.png">

Android
<img width="299" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/35e4fd8b-9f38-44a0-fd5d-616294610406.png" alt="スクリーンショット">

色を指定しない場合の標準デザインはこちら

iOS
<img width="375" alt="スクリーンショット 2019-12-21 23.08.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/cc53f309-3516-89d7-d6c7-202e93699112.png">

Android
<img width="292" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/d154adf3-6e2f-69e1-9de7-a4da183d6fa4.png" alt="スクリーンショット">

## 選択UIを作成

通常iOSではドラムロールで表示されるPickerを使用しますが、ここではドロップダウン型のものを作ってみます。
少しだけ複雑なコンポーネントになるため、必要とあれば既成のUIコンポーネント類を使用したいのですが、ことドロップダウンに関してはあまり需要がないのか、良いものが見つかりませんでした。（参考：https://qiita.com/zaburo/items/7e2d2f0f6b9317a7789e）

車輪の再発明かもしれませんが、それほど難しいものではないので自作してみます。
まず全体のソースはこちら

```jsx:Select.js
import React, { PureComponent } from "react";
import * as PropTypes from "prop-types";
import {
  Dimensions,
  FlatList,
  Modal,
  Text,
  View,
  StyleSheet,
  TouchableWithoutFeedback,
  TouchableOpacity
} from "react-native";
import Icon from "@expo/vector-icons/Ionicons";
import colors from "../constants/colors";
import FormField from "./FormField";

const styles = StyleSheet.create({
  container: {
    position: "relative",
    width: "100%"
  },
  current: {
    width: "100%",
    height: 50,
    lineHeight: 50,
    justifyContent: "center",
    paddingHorizontal: 10,
    borderWidth: 1,
    borderColor: colors.border,
    backgroundColor: colors.white,
    borderRadius: 4,
    fontSize: 16
  },
  arrow: {
    position: "absolute",
    top: 13,
    right: 16
  },
  option: {
    width: "100%",
    height: 49,
    lineHeight: 49,
    justifyContent: "center",
    paddingHorizontal: 10,
    backgroundColor: colors.white,
    fontSize: 16
  },
  separator: {
    width: "100%",
    height: 1,
    backgroundColor: colors.border
  },
  modal: {
    width: "100%",
    height: "100%"
  },
  modalBg: {
    position: "absolute",
    width: "100%",
    height: "100%",
    top: 0,
    left: 0
  },
  modalInner: {
    position: "absolute",
    height: "100%",
    zIndex: 1,
    borderRadius: 4,
    borderWidth: 1,
    borderColor: colors.border,
    shadowColor: colors.black,
    shadowOpacity: 0.3,
    shadowRadius: 3,
    shadowOffset: {
      width: 2,
      height: 2
    },
    elevation: 4
  },
  list: {
    backgroundColor: colors.white,
    borderRadius: 4
  }
});

class Select extends PureComponent {
  state = {
    active: false
  };

  /**
   * ドロップダウンを表示
   */
  open = () => {
    // 絶対座標で表示するために要素の位置・サイズを取得
    this.currentComponent.measureInWindow((x, y, width, height) => {
      const { maxHeight, minHeight, options } = this.props;
      const windowHeight = Dimensions.get("window").height;
      let modalY = y;
      const modalMinHeight = minHeight
        ? Math.min(options.length * height, minHeight)
        : null;
      let modalMaxHeight = Math.min(windowHeight - y, maxHeight);
      if (modalMinHeight > modalMaxHeight) {
        // 選択肢が下に見切れる場合は上向きに表示する
        modalMaxHeight = Math.min(y + height, maxHeight);
        modalY = y + height - modalMaxHeight;
      }
      this.setState({
        active: true,
        x,
        y: modalY,
        width,
        height,
        minHeight: modalMinHeight,
        maxHeight: modalMaxHeight
      });
    });
  };

  /**
   * ドロップダウンを非表示
   */
  dismiss = () => {
    const { setFieldTouched } = this.props;
    setFieldTouched(true);
    this.setState({ active: false });
  };

  render() {
    const { active, x, y, width, height, minHeight, maxHeight } = this.state;
    const { value, options, placeholder, setFieldValue } = this.props;
    const selectedOption = options[value];
    return (
      <View style={styles.container}>
        <TouchableOpacity onPress={this.open}>
          <View>
            <Text
              ref={component => {
                this.currentComponent = component;
              }}
              style={styles.current}
              suppressHighlighting
            >
              {selectedOption ? selectedOption.label : placeholder}
            </Text>
            <Icon
              name="ios-arrow-down"
              color={colors.border}
              size={24}
              style={styles.arrow}
            />
          </View>
        </TouchableOpacity>
        <Modal visible={active} transparent={true}>
          <View style={styles.modal}>
            <TouchableWithoutFeedback onPressIn={this.dismiss}>
              <View style={styles.modalBg} />
            </TouchableWithoutFeedback>
            <View
              style={[
                styles.modalInner,
                {
                  left: x,
                  top: y,
                  width: width,
                  minHeight,
                  maxHeight
                }
              ]}
            >
              <FlatList
                data={options}
                ItemSeparatorComponent={() => <View style={styles.separator} />}
                keyExtractor={item => item.label}
                initialScrollIndex={value}
                getItemLayout={(data, index) => ({
                  length: height,
                  offset: height * index,
                  index
                })}
                style={styles.list}
                renderItem={({ item, index }) => (
                  <TouchableOpacity
                    onPress={() => {
                      this.dismiss();
                      setFieldValue(index);
                    }}
                  >
                    <Text style={styles.option} suppressHighlighting>
                      {item.label}
                    </Text>
                  </TouchableOpacity>
                )}
              />
            </View>
          </View>
        </Modal>
      </View>
    );
  }
}

Select.defaultProps = {
  selectedIndex: -1,
  maxHeight: 225,
  minHeight: 125,
  placeholder: "選択してください"
};

Select.propTypes = {
  value: PropTypes.number,
  options: PropTypes.arrayOf(
    PropTypes.shape({
      label: PropTypes.string.isRequired,
      value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
        .isRequired
    })
  ).isRequired,
  placeholder: PropTypes.string,
  maxHeight: PropTypes.number
};

export default FormField(Select);
```

現在の値、あるいはプレースホルダーを表示しておき、タップしたら選択肢を表示すればよいわけです。

ここで考慮するのは、

* React NativeのModalを使用する（FlatListをそのまま使うと、ページ自体のスクロールと二重になってしまうため）
* 選択フィールドの位置・サイズを取得して同じ絶対位置に選択肢を表示する
* 選択肢が画面の下に見切れてしまわないように高さを調整する
* propsでセットした最小の高さより小さくなりそうな場合は上向きに表示させる

といったところです。

propsの形式などはラジオボタンの時と合わせて、都道府県の選択フィールドを作ってみます。

```js:prefectures.js
export default [
  { value: 1, label: "北海道" },
  ...
];
```

選択肢のデータを用意しておき、

```js:App.js
const schema = Yup.object().shape({
  ...
  prefecture: Yup.number()
    .oneOf(
      prefectures.map(option => option.value),
      "地域を選択してください"
    )
    .nullable()
    .required("地域を選択してください"),
  ...
});
```

ラジオボタンと同じく念の為選択肢の値以外のものが入らないようにしておきます。
また、ラジオボタンと違い一度選択肢を表示してから何も選ばず閉じる場合もあるため、選択していない場合の値`null`が入っても型が違うというエラーが出ないようスキーマを定義（`nullable`）しておきます。

```jsx:App.js
<Select
  label="お住いの地域"
  name="prefecture"
  options={prefectures}
/>
```

コンポーネントを使うところはラジオボタンと同じです。

<img width="375" alt="PNGイメージ 9.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/234d5e19-a442-3dd8-8a96-8a16354fbb38.png">
<img width="375" alt="PNGイメージ 8.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/88801dc9-0b9f-bf08-67d7-c75c770308d4.png">
<img width="375" alt="PNGイメージ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/3b18454e-52ea-a850-b1fc-6c2ba4d61d66.png">

## ファイル入力（画像）を作成

画像をカメラロールから選択するフィールドを作成します。
コンポーネント側ではボタンと、`expo-image-picker`の処理、サムネイル表示を用意し、`expo-image-picker`で取得した一時ファイルのURLを値としてFormikに渡すようにします。
サムネイルはローディング時の表示などよしなにやってくれる`react-native-elements`の[`Avatar`](https://react-native-elements.github.io/react-native-elements/docs/avatar.html)を流用してみます。

```jsx:ImageSelect.js
import React from "react";
import * as PropTypes from "prop-types";
import { View, StyleSheet } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { Avatar } from "react-native-elements";
import Button from "./Button";
import FormField from "./FormField";

const styles = StyleSheet.create({
  container: {
    width: "100%"
  },
  thumbnail: {
    marginBottom: 8
  }
});

function ImageSelect(props) {
  const { title, value, setFieldValue, setFieldTouched } = props;

  async function select() {
    const { status } = await ImagePicker.requestCameraRollPermissionsAsync();
    if (status === "granted") {
      const { cancelled, uri } = await ImagePicker.launchImageLibraryAsync();
      setFieldTouched(true);
      if (!cancelled && uri) {
        setFieldValue(uri);
      }
    }
  }

  return (
    <View style={styles.container}>
      <Avatar
        containerStyle={styles.thumbnail}
        source={value ? { uri: value } : null}
        size="large"
      />
      <Button title={title} onPress={select} />
    </View>
  );
}

ImageSelect.defaultProps = {
  title: "画像を選択"
};

ImageSelect.propTypes = {
  title: PropTypes.string
};

export default FormField(ImageSelect);
```

```js:App.js
const schema = Yup.object().shape({
  ...
  image: Yup.string()
    .nullable()
    .required("画像が必要です"),
  ...
});

...
<ImageSelect label="アカウント画像" name="image" />
...
```

と、ここまでは簡単なのですが、`expo-image-picker`で取得した画像URLはアプリの起動中に一時的にキャッシュされている画像ファイルのURLなので、後ほど送信する際はこれをダウンロードしてから保存する必要があります。

その部分は後述するとして、最後に日付選択フィールドを作成します。

## 日付選択を作成

React Nativeではまず標準のものが （https://github.com/react-native-community/react-native-datetimepicker） 用意されていますが、iOSではページ内にそのまま表示されてしまい使い勝手が悪いため、これをモーダル内に表示する[react-native-modal-datetime-picker](https://github.com/mmazzarolo/react-native-modal-datetime-picker)を使用します。

[Githubの説明](https://github.com/mmazzarolo/react-native-modal-datetime-picker/blob/next-major/README.md)にしたがって最新のパッケージをインストールしてください。
また、Date型を扱いやすくするため`moment`もインストールします。

```console
$ npm i react-native-modal-datetime-picker@8.x.x @react-native-community/datetimepicker moment
```

コンポーネントではプレースホルダー、あるいは現在選択されている日付をタイトルとしたボタンを表示し、適宜`isVisible`の切り替えを行います。
今回は時間はなく日付のみ選択させる想定なので、Formikで管理する値は日付の最初の時刻（0時00分0秒0）を取るようにします。（`moment`の`startOfDay`を利用）

```jsx:DatePicker.js
import React, { useState } from "react";
import * as PropTypes from "prop-types";
import moment from "moment";
import Button from "./Button";
import DateTimePickerModal from "react-native-modal-datetime-picker";
import FormField from "./FormField";

function DatePicker(props) {
  const { value, title, placeholder, setFieldValue, setFieldTouched } = props;
  const [active, setActive] = useState(false);

  const open = () => {
    setActive(true);
  };

  const dismiss = () => {
    setActive(false);
    setFieldTouched(true);
  };

  return (
    <>
      <DateTimePickerModal
        isVisible={active}
        date={
          value ||
          moment()
            .startOf("day")
            .toDate()
        }
        onConfirm={date => {
          dismiss();
          setFieldValue(moment(date).startOf('day').toDate());
        }}
        headerTextIOS={title}
        cancelTextIOS="キャンセル"
        confirmTextIOS="OK"
        onCancel={dismiss}
        locale="ja"
      />
      <Button
        title={value ? moment(value).format("YYYY年MM月DD日") : placeholder}
        onPress={open}
      />
    </>
  );
}

DatePicker.defaultProps = {
  title: "日付を選択してください",
  placeholder: "日付を選択してください"
};

DatePicker.propTypes = {
  title: PropTypes.string,
  placeholder: PropTypes.string
};

export default FormField(DatePicker);
```

```jsx:App.js
const schema = Yup.object().shape({
  ...
  date: Yup.date()
    .nullable()
    .required("日付を選択してください")
  ...
});

<DatePicker
  label="日付"
  title="日付を選択"
  placeholder="日付を選択"
  name="date"
/>
```

YupではDate型も使用できます。

iOS
<img width="375" alt="PNGイメージ 10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/9f9ce3d2-29f9-db9d-691b-eeb7abda7ed7.png">
<img width="375" alt="スクリーンショット 2019-12-22 14.12.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0d2453b8-6281-bc2f-7161-520115eb9f36.png">
<img width="375" alt="スクリーンショット 2019-12-22 14.13.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/b9d08009-4f09-15ce-a040-1cc5077a7c97.png">

Androidはカレンダー式の表示です。タイトルは表示されません。
<img width="375" alt="スクリーンショット" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/d2d012fb-5dfc-fa42-369d-071f54a501cc.png">


## Firestore / Firebase Storageに保存

最後に、入力した値をFirebaseに送信して保存する例を実装してみたいと思います。
最初に載せたソースの時点で送信ボタンをタップしたら`onSubmit`メソッドが呼ばれるようにしてあるので、そちらに書いていきます。

```js:App.js
import firebase from 'firebase';
import 'firebase/firestore';
const db = firebase.firestore();

  ...
  onSubmit = async (values, actions) => {
    try {
      await db.collection("/members").add(values);
      actions.resetForm();
      Alert.alert("送信できました");
    } catch (error) {
      Alert.alert("送信に失敗しました", error.message);
    }
  };
  ...
```

単純な例ですが、これでFormikから渡されたvaluesをシンプルに`members`コレクションのドキュメントとして登録することができます。
Formikから第二引数に[FormikBag](https://jaredpalmer.com/formik/docs/api/withFormik#handlesubmit-values-values-formikbag-formikbag-void-promise-any)オブジェクトが入り、`setSubmitting`で送信状態を操作したり、値をリセットしたりすることができます。
`isSubmitting`を見て送信中はボタンがdisabledになるようにしていますが、Formikの`onSubmit`に渡した関数がPromiseを返す(この例ではasync function)場合には自動的に`isSubmitting`がセットされるので、明示的に`setSubmitting`する必要はありません。この例では、送信が完了したらフォームをリセットするようにのみ処理しています。

先ほど作成したようなファイル入力フィールドを使用している場合は、もう一手間必要です。
Firebaseの設定の方でStorageを有効にしてから、画像をfetch→Blob化してStorageに保存→Storage上のURLを取得という流れで画像を保存しFirestoreのドキュメントに紐付けます。

```js:App.js
  onSubmit = async (values, actions) => {
    try {
      const localImageUrl = values.image; // 画像URL（キャッシュ）を保持
      delete values.image; // Firestoreに最初に保存する際は画像URL(キャッシュ)を削除
      const docRef = await db.collection("/members").add(values);
      const { imageUrl } = await this.submitImage(localImageUrl, docRef.id);
      await docRef.update({ imageUrl }); // 画像URLを登録
      actions.resetForm();
      Alert.alert("送信できました");
    } catch (error) {
      Alert.alert("送信に失敗しました", error.message);
    }
  };

  // 画像を保存する
  submitImage = async (localImageUrl, memberId) => {
    const imagePath = `members/${memberId}.jpg`; // 画像の位置を決める（とりあえずmemberのidを使う）
    const imageRef = storage.ref().child(imagePath); // refを作成
    const imageResponse = await fetch(localImageUrl); // キャッシュから画像ファイルをダウンロード
    const blob = await imageResponse.blob(); // Blob化
    const snapshot = await imageRef.put(blob); // Storageに保存
    const imageUrl = await imageRef.getDownloadURL(); // URLを取得
    return { imageUrl, snapshot };
  };
```

作成された`members`のドキュメントの`imageUrl`フィールドにStorage上のURLが保存されていれば成功です。

# Github

Firebaseへの送信処理を抜いたものですが、サンプルのソースはGithubに公開しています。
https://github.com/mildsummer/react-native-form-example

## 未掲載のソース

最終的にApp.jsはこんな感じになりました。

```jsx:App.js
import React, { Component } from "react";
import {
  StyleSheet,
  TouchableWithoutFeedback,
  Keyboard,
  View,
  ScrollView,
  Alert
} from "react-native";
import { Formik } from "formik";
import * as Yup from "yup";
import { db, storage } from "./app/utils/firebase";

import Select from "./app/components/Select";
import Input from "./app/components/Input";
import ImageSelect from "./app/components/ImageSelect";
import RadioButton from "./app/components/RadioButton";
import Button from "./app/components/Button";
import CheckBox from "./app/components/CheckBox";
import Switch from "./app/components/Switch";
import DatePicker from "./app/components/DatePicker";
import prefectures from "./app/constants/prefectures";

const genderOptions = [
  { label: "男性", value: 0 },
  { label: "女性", value: 1 }
];

const schema = Yup.object().shape({
  name: Yup.string()
    .min(3, "3文字以上で入力してください")
    .max(20, "20文字以内で入力してください")
    .required("氏名を入力してください"),
  gender: Yup.number()
    .oneOf(
      genderOptions.map(option => option.value),
      "性別を選択して下さい"
    )
    .required("性別を選択して下さい"),
  prefecture: Yup.number()
    .oneOf(
      prefectures.map(option => option.value),
      "地域を選択してください"
    )
    .nullable()
    .required("地域を選択してください"),
  terms: Yup.bool().oneOf([true], "同意が必要です"),
  notification: Yup.bool(),
  image: Yup.string()
    .nullable()
    .required("画像が必要です"),
  date: Yup.date()
    .nullable()
    .required("日付を選択してください")
});

const styles = StyleSheet.create({
  container: {
    width: "100%",
    padding: 24,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center"
  },
  form: {
    width: "100%"
  }
});

export default class App extends Component {
  onSubmit = async (values, actions) => {
    try {
      const localImageUrl = values.image;
      delete values.image;
      const docRef = await db.collection("/members").add(values);
      const { imageUrl } = await this.submitImage(localImageUrl, docRef.id);
      await docRef.update({ imageUrl });
      actions.resetForm();
      Alert.alert("送信できました");
    } catch (error) {
      Alert.alert("送信に失敗しました", error.message);
    }
  };

  submitImage = async (localImageUrl, memberId) => {
    const imagePath = `members/${memberId}.jpg`;
    const imageRef = storage.ref().child(imagePath);
    const imageResponse = await fetch(localImageUrl);
    const blob = await imageResponse.blob();
    const snapshot = await imageRef.put(blob);
    const imageUrl = await imageRef.getDownloadURL();
    return { imageUrl, snapshot };
  };

  render() {
    return (
      <ScrollView contentContainerStyle={styles.container}>
        <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
          <View style={styles.form}>
            <Formik
              initialValues={{
                name: "",
                gender: null,
                prefecture: null,
                terms: false,
                notification: false,
                date: null,
                image: null
              }}
              validateOnMount
              validationSchema={schema}
              onSubmit={this.onSubmit}
            >
              {({ handleSubmit, isValid, isSubmitting }) => (
                <>
                  <Input
                    label="氏名"
                    name="name"
                    placeholder="氏名を入力してください"
                  />
                  <RadioButton
                    label="性別"
                    name="gender"
                    options={genderOptions}
                  />
                  <Select
                    label="お住いの地域"
                    name="prefecture"
                    options={prefectures}
                  />
                  <CheckBox label="同意事項" title="同意する" name="terms" />
                  <Switch label="通知" name="notification" />
                  <ImageSelect label="アカウント画像" name="image" />
                  <DatePicker
                    label="日付"
                    title="日付を選択"
                    placeholder="日付を選択"
                    name="date"
                  />
                  <Button
                    title="Submit"
                    onPress={handleSubmit}
                    disabled={!isValid || isSubmitting}
                  />
                </>
              )}
            </Formik>
          </View>
        </TouchableWithoutFeedback>
      </ScrollView>
    );
  }
}
```

```js:app/utils/firebase.js
import firebase from "firebase";
import "firebase/firestore";
import "firebase/storage";

firebase.initializeApp(...); // ここは各自のプロジェクトで

export const db = firebase.firestore();
export const storage = firebase.storage();
export default firebase;
```

ボタンもデザインを共通化できるよう`react-native-elements`のボタンをラップしています。

```jsx:Button.js
import React from "react";
import { StyleSheet } from "react-native";
import { Button as RNButton } from "react-native-elements";
import colors from "../constants/colors";

const styles = StyleSheet.create({
  container: {
    width: "100%"
  },
  button: {
    height: 50,
    borderColor: 4,
    backgroundColor: colors.accent
  }
});

function Button(props) {
  const { title, disabled, loading, onPress } = props;
  return (
    <RNButton
      title={title}
      disabled={disabled}
      loading={loading}
      containerStyle={styles.container}
      buttonStyle={styles.button}
      onPress={onPress}
    />
  );
}

export default Button;
```

```app/constants/colors.js
export default {
  white: "#FFFFFF",
  black: "#000000",
  text: "#212121",
  border: "#BBBBBB",
  accent: "#3D5AFE",
  lightGray: "#DDDDDD",
  red: "#F50057"
};
```