~fvknk/bk

技術関連の備忘録

ReactNativeで家計簿アプリを作りたい(前編)

概要

以下のような家計簿アプリが欲しかったのですがうまく見つけられなかったので、自作してみることにしました。

  • ios対応
  • 複数人で使用できる
  • アカウント・パスワードの共有はしない
  • 共有財布機能がある
  • 月々の振り込みを個人(=非共有財布)で行い、任意のタイミングで共通財布から出費分の補填(以下、精算と呼ぶ)をする
  • 月々共有財布に一定額割り当て、余った分は貯蓄する

やったこと

フローの検討

PlantUMLを使って、概要に記載した要求が満たせそうな、基本的な業務機能関連図のようなものを整理しました。

ざっくりとした画面イメージ

業務機能関連図からかなりざっくりとイメージを描きました。

データの検討

業務機能関連図を見ながらPlantUMLを使って必要そうなテーブル・データを書き出しました。

フレームワークの検討

アプリ作成に際し、開発のフレームワークとして、以下が候補としてあがりました。

  • Swift
  • Flutter
  • ReactNative

最終的には私がReactに多少触れたことがあり、技術的なギャップが比較的少なそうと感じたことからReactNativeを採用することにしました。

環境整備

エミュレータは実機で良いので、面倒ごとが少なそうなExpo Go Quick Startで進めました。

reactnative.dev

上記リンクに記載の通りに実行すると無事に起動しました。

$ npx create-expo-app AwesomeProject

$ cd AwesomeProject
$ npx expo start
tarting Metro Bundler
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
█ ▄▄▄▄▄ █▄▄▄ ▀ ██ █ ▄▄▄▄▄ █
█ █   █ ██▄▀ █ ▀▄▄█ █   █ █
█ █▄▄▄█ ██▀▄ ▄▀▀█▀█ █▄▄▄█ █
█▄▄▄▄▄▄▄█ ▀▄█ ▀ ▀ █▄▄▄▄▄▄▄█
█ ▄▀ ▄▀▄▀▀▄▀█▄▀█▀ █▄█▀█▀▀▄█
█▄▀▀▄██▄▄▄▄██▄▄▄▄ ▀███▄▀▀ █
█ ▀▄▀▄ ▄██▄ █▀█▄ █ ▄▀▀█▀ ██
█ ▄▄▄▀▄▄▄▄▄ █▀▄▀ ▄▀ ██▄▀  █
█▄██▄█▄▄▄  ▀ ▄▄ █ ▄▄▄  ▄▀▄█
█ ▄▄▄▄▄ ██▀ ▀▄  █ █▄█ ███ █
█ █   █ █ ▄█▄ ▀█▄ ▄  ▄ █▀▀█
█ █▄▄▄█ █▀▄▄ ▀█▄ ▄█▀▀▄█   █
█▄▄▄▄▄▄▄█▄█▄▄██▄▄▄▄█▄▄███▄█

› Metro waiting on exp://192.168.1.146:8081
› Scan the QR code above with Expo Go (Android) or the Camera app (iOS)

› Using Expo Go
› Press s │ switch to development build

› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web

› Press j │ open debugger
› Press rreload app
› Press m │ toggle menu
› Press o │ open project code in your editor

› Press ? │ show all commands

Logs for your project will appear below. Press Ctrl+C to exit.

手持ちのiPhoneでExpo GOをインストールしてからQRコードを読み込むと、以下のような画面が表示されました。

問題なく動作していそうです。

支出登録フォームを作成する

テキスト入力欄を作成する

ReactNative formで検索するとreact-hook-formというライブラリが比較的多く引っかかったので、react-hook-formを導入しました。

念の為react-hook-formのGitHubも見たのですが、スター数も十分多く、定期的にメンテナンスもされていそうなので比較的安心かなという感じで導入を決めています。

https://github.com/react-hook-form/react-hook-form

導入自体は以下に沿って行いました。

react-hook-form.com

以下のようにコンポーネントを作成してスマホで確認したところ、問題なく表示・動作しました。

import { Text, View, TextInput, Button } from "react-native";
import { useForm, Controller } from "react-hook-form";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors }
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
    },
  });
  const onSubmit = async (data) => console.log(data);

  return (
    <View>
      <Text>金額</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="金額"
            placeholder="¥"
            onBlur={onBlur}
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="price"
          />
        )}
        name="price"
      />
      {errors.price && <Text>price is required.</Text>}

      <Text>店</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="店"
            placeholder="Amazon"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            testID="shop"
          />
        )}
        name="shop"
      />
      {errors.shop && <Text>shop is required.</Text>}

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

金額の入力欄にフォーカスすると数値キーボードが表示されます。

日付入力欄を作成する

以下を確認したところ、日付入力には現在はコミュニティで公開されているライブラリを使ってねということらしいです。

reactnative.dev

とりあえず、スター数の多いreact-native-datetimepicker/datetimepickerをGitHubページを見ながら導入をしました。

github.com

実装自体は下記のようにしています。 また、onChangeを使うと登録ボタンを押した際に日付の変更がうまく反映されなかったため、setValuesで設定するように変更しています。

import { Text, View, TextInput, Button } from "react-native";
import { useForm, Controller } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
    setValue,
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      date: new Date(),
    },
  });
  const setDate = (_event, date) => {
    setValue("date", date);
  };
  const onSubmit = async (data) => console.log(data);

  return (
    <View>
      {/* 省略 */}
      {errors.shop && <Text>shop is required.</Text>}

      <Text>日付</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { value } }) => (
          <DateTimePicker
            mode="date"
            onChange={setDate}
            value={value}
            testID="date"
          />
        )}
        name="date"
      />
      {errors.date && <Text>date is required.</Text>}

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

画面でも問題なく表示ができました。

他のコンポーネントに登録内容を表示する

上記をFormコンポーネントとして、Listコンポーネントへ登録内容を表示する処理の実装を行いました。 (将来的には別画面にするのですが、現状は動作確認も兼ねて行っています。)

この時点でFormコンポーネントの呼び出しが以下のようになっていました。

import React from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";

export default function App() {
  return (
    <SafeAreaView style={styles.container}>
      <Form />
    </SafeAreaView>
  );
}

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

そのため以下の2点を行いました。

  • <Form /> から呼び出し元に値を渡す
  • 呼び出し元から<List />へ値を渡す。

まず、適当に一覧画面用のListコンポーネントを呼び出し、適当な値を渡します。

// 親コンポーネント
import React from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";
import List from "./components/List";

export default function App() {
  const items = [
    {
      id: 1,
      price: 100,
      shop: "Amazon",
      date: "2023/12/10",
    },
    {
      id: 2,
      price: 100,
      shop: "Amazon",
      date: "2023/12/12",
    },
  ];

  return (
    <SafeAreaView style={styles.container}>
      <List items={items} />
      <Form />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});
// 一覧表示
import React from "react";
import { View, FlatList, Text } from "react-native";

const Item = ({ item }) => (
  <View>
    <Text>
      {item.date}:{item.price}({item.shop})
    </Text>
  </View>
);

export default function List({ items }) {
  return (
    <FlatList
      data={items}
      renderItem={({ item }) => <Item item={item} />}
      keyExtractor={(item) => item.id}
      testID="list"
    />
  );
}

見た目はイマイチですが、画面でも問題なく表示できていそうです。

次に、<Form />から親コンポーネントへのデータの受け渡しを実装しました。 実装自体はReactのuseStateを使って行いました。

ja.react.dev

// 親コンポーネント
import React, { useState } from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";
import List from "./components/List";

export default function App() {
  const [items, setItems] = useState([
    {
      id: 1,
      price: 100,
      shop: "Amazon",
      date: "2023/12/10",
    },
    {
      id: 2,
      price: 100,
      shop: "Amazon",
      date: "2023/12/12",
    },
  ]);

  return (
    <SafeAreaView style={styles.container}>
      <List items={items} />
      <Form
        setItems={(data) =>
          setItems([...items, { id: items.length + 1, ...data }])
        }
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});
// 登録用コンポーネント
import { Text, View, TextInput, Switch, Button } from "react-native";
import { useForm, Controller, useWatch } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      date: new Date(),
    },
  });
  const onSubmit = async (data) => {
    await setItems({
      price: data.price,
      shop: data.shop,
      date: data.date.toLocaleDateString(),
    });
  };

  return (
    {/* 省略 */}

画面でも問題なく受け渡しができていそうです。

定期支出登録との画面の切り替えを作る

次に、フォーム画面の、定期支出の登録用トグルと画面の切り替えを実装しました。 1「週間」ごと、1「月」ごとなどの単位選択をプルダウンにしたかったのですが、ReactNativeでは提供していないようなので、以下を参考にreact-native-picker-selectを導入しました。

cpoint-lab.co.jp github.com

トグルでの表示切り替えはuseWatchを使ってトグルの値の監視するようにしました。

react-hook-form.com

また、<Switch />を使うと、値がfalseのときにとくにメッセージのないエラーが出てしまい、バリデーションが通らなくなってしまったいました。 そのため、ここではいったんclearErrorsで対象のエラーを削除しました。 (本当のエラーが出る可能性もあるので、後々良さそうな方法を見つけたら修正したい……)

最終的に以下のようなコードになっています。

import { Text, View, TextInput, Switch, Button } from "react-native";
import { useForm, Controller, useWatch } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";
import RNPickerSelect from "react-native-picker-select";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
    setValue,
    clearErrors,
    resetField,
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      isRegular: false,
      // isRegular = false の場合に表示される
      date: new Date(),
      // isRegular = true の場合に表示される
      intervalValue: "1",
      unit: "monthly",
    },
  });
  const toggleIsRegular = (isRegular) => {
    setValue("isRegular", isRegular);
    // isRegular = false の際に出る中身のないエラーを消す
    clearErrors("isRegular");
  };
  // isRegular の値を監視
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });
  const onSubmit = async (data) => {
    await setItems({
      price: data.price,
      shop: data.shop,
      date: data.date.toLocaleDateString(),
    });
    resetField("price");
    resetField("shop");
    resetField("intervalValue");
    resetField("unit");
  };

  return (
    <View>
      <Text>金額</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="金額"
            placeholder="¥"
            onBlur={onBlur}
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="price"
          />
        )}
        name="price"
      />
      {errors.price && <Text>price is required.</Text>}

      <Text>店</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="店"
            placeholder="Amazon"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            testID="shop"
          />
        )}
        name="shop"
      />
      {errors.shop && <Text>shop is required.</Text>}

      <Text>定期支出</Text>
      <Controller
        control={control}
        render={({ field: { value } }) => (
          <Switch
            onValueChange={toggleIsRegular}
            value={value}
            testID="isRegular"
          />
        )}
        name="isRegular"
      />

      <View>
        {isRegular ? (
          <RegularScheduleForm
            control={control}
            setValue={setValue}
            errors={errors}
          />
        ) : (
          <DateForm control={control} errors={errors} />
        )}
      </View>

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

function DateForm({ control, setValue, errors }) {
  const setDate = (_event, date) => {
    setValue("date", date);
  };
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });

  return (
    <>
      <Text>日付</Text>
      <Controller
        control={control}
        rules={{
          validate: {
            isFilled: (value) => (!isRegular ? !!value : true),
          },
        }}
        render={({ field: { onChange, value } }) => (
          <DateTimePicker
            mode="date"
            onChange={setDate}
            value={value}
            testID="date"
          />
        )}
        name="date"
      />
      {errors.date && <Text>date is required.</Text>}
    </>
  );
}

function RegularScheduleForm({ control, errors }) {
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });

  return (
    <>
      <Controller
        control={control}
        rules={{
          validate: {
            isFilled: (value) => (isRegular ? !!value : true),
          },
        }}
        render={({ field: { onChange, value } }) => (
          <TextInput
            placeholder="1"
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="intervalValue"
          />
        )}
        name="intervalValue"
      />

      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, value } }) => (
          <RNPickerSelect
            onValueChange={onChange}
            items={[
              { value: "daily", label: "日" },
              { value: "weekly", label: "週間" },
              { value: "monthly", label: "月" },
              { value: "yearly", label: "年" },
            ]}
            value={value}
          />
        )}
        name="unit"
      />
      <Text>に1回</Text>

      {errors.intervalValue && <Text>intervalValue is required.</Text>}
      {errors.unit && <Text>unit is required.</Text>}
    </>
  );
}

セレクトボックスは問題なく動いていました。

また、トグルでの表示切り替えも問題なさそうでした。

結構長くなってしまったので、今回は一度この辺りで記事は切りたいと思います。 気が向いたら続きも書いてリンクを記載しておきます。