概要
以下のような家計簿アプリが欲しかったのですがうまく見つけられなかったので、自作してみることにしました。
- ios対応
- 複数人で使用できる
- アカウント・パスワードの共有はしない
- 共有財布機能がある
- 月々の振り込みを個人(=非共有財布)で行い、任意のタイミングで共通財布から出費分の補填(以下、精算と呼ぶ)をする
- 月々共有財布に一定額割り当て、余った分は貯蓄する
やったこと
フローの検討
PlantUMLを使って、概要に記載した要求が満たせそうな、基本的な業務機能関連図のようなものを整理しました。
ざっくりとした画面イメージ
業務機能関連図からかなりざっくりとイメージを描きました。
データの検討
業務機能関連図を見ながらPlantUMLを使って必要そうなテーブル・データを書き出しました。
フレームワークの検討
アプリ作成に際し、開発のフレームワークとして、以下が候補としてあがりました。
- Swift
- Flutter
- ReactNative
最終的には私がReactに多少触れたことがあり、技術的なギャップが比較的少なそうと感じたことからReactNativeを採用することにしました。
環境整備
エミュレータは実機で良いので、面倒ごとが少なそうなExpo Go Quick Startで進めました。
上記リンクに記載の通りに実行すると無事に起動しました。
$ 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 r │ reload 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
導入自体は以下に沿って行いました。
以下のようにコンポーネントを作成してスマホで確認したところ、問題なく表示・動作しました。
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> ); }
金額の入力欄にフォーカスすると数値キーボードが表示されます。
日付入力欄を作成する
以下を確認したところ、日付入力には現在はコミュニティで公開されているライブラリを使ってねということらしいです。
とりあえず、スター数の多いreact-native-datetimepicker/datetimepickerをGitHubページを見ながら導入をしました。
実装自体は下記のようにしています。
また、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
を使って行いました。
// 親コンポーネント 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を導入しました。
トグルでの表示切り替えはuseWatch
を使ってトグルの値の監視するようにしました。
また、<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>} </> ); }
セレクトボックスは問題なく動いていました。
また、トグルでの表示切り替えも問題なさそうでした。
結構長くなってしまったので、今回は一度この辺りで記事は切りたいと思います。 気が向いたら続きも書いてリンクを記載しておきます。