🪫

Typescript + MUI + firebaseでミニアプリ【寿電池】を作る

2023/09/13に公開

今回は通勤時間に中田敦彦のyoutube大学を見ながら思いついたので、MaterialUI v5を試してみたかったのでサクッと作ってみました。

概要

目標寿命に対しての残りの寿命がスマホのバッテリーのように表示されるアプリです。
1日1日をより濃く、意識するのではないか?と思い作りました。
このアプリの結果を見て嫌な気分になる方もいるかもしれません。その方は使用を避けてください。

使い方

① 目標寿命を入力する
② 現在の年齢を入力する
③ STARTを押下する

入力画面

結果画面

サイトのURL貼っておきます

https://jyudenchi-d0080.web.app/

準備

$ node -v
v18.12.1
$ npm -v
8.19.2
$ yarn -v
1.22.19

DBは使用しておりません。

ソースコードは公開しています。以下に貼っておきます。
https://github.com/syukihiro/jyudenchi

プロジェクトの作成

$ npx create-react-app jyudenchi --template typescript

MUI導入

$ npm install @mui/material

こちらの記事参考にさせていただきました。
https://ralacode.com/blog/post/react-material-ui/

バージョン違いによる脆弱性のエラーが出たので、以下のコマンドをrunしてくださいとのメッセージでコマンドを入力して解消。

$ npm audit fix --force

実装の解説

環境構築がここまでで終わったので、これから実装に移っていきます。
Figumaであらかじめデザインは作ってあったので、小さいコンポーネントから作っていきます。
今回はミニアプリなのでディレクトリ構成は至ってシンプルです。
src直下にcomponentディレクトリを作成します。
まず、テキストフィールドを作っていきます。

定義

用意する定義は

  1. 目標とする年齢
  2. 現在年齢
  3. それぞれのエラーメッセージ
  4. 結果ページを表示フラグ
  5. エラー判定フラグ
  const [goalage, setGoalage] = useState<number>();
  const [age, setAge] = useState<number>();
  const [goalageErr, setGoalageErr] = useState<string>();
  const [ageErr, setAgeErr] = useState<string>();
  const [openFlg, setOpenflg] = useState<boolean>(false);
  const disabledFlg = goalage && age && goalageErr == undefined && ageErr == undefined ? false : true;

テキストフィールド

仕様

  1. 数値のみ入力
  2. 全角も入力可能
  3. 数値以外の入力の時はエラーメッセージ表示(常時監視)

TextFiledコンポーネントのコード↓

import React from 'react';
import { TextField } from '@mui/material';

interface Props {
  label: string;
  type: number;
  helperText?: string;
  onChange: (data: string, type: number) => void;
}

export function TextFileld({ label, type, helperText, onChange }:Props) {
  return (
    <>
      <TextField
        inputProps={{ inputMode: 'numeric' }}
        id="outlined-basic"
        label={label}
        focused={true}
        color={helperText ? "error" : "primary"}
        error={helperText ? true : false }
        helperText={helperText}
        margin="normal"
        fullWidth
        variant="outlined"
        onChange={(e) => onChange(e.target.value, type)}
      />
    </>
  );
}

■まず反省点
テキストフィールドのみで完結できるonChange内の処理を外だししてAppで作ってしまったところ。

  • エラー判定処理など

helperTextをエラーメッセージとして使用することにしました。
エラーメッセージの内容はそれぞれ違うことを想定してpropsで渡しています。
helperTextの存在しているかどうかでエラーの判定もしています。

 color={helperText ? "error" : "primary"}
 error={helperText ? true : false }

ボタン

ボタンに関しては複雑な処理はしなかったので、そのままMUIのコンポーネントを使用しました。
ただSTARTするボタンは「正常値が入力されている時だけ有効にする」ことにしました。

Startボタン

<Button variant="contained" fullWidth size="large" disabled={disabledFlg} onClick={() => hanbleClickBtn(true)}>START</Button>

disabledFlgの値はそれぞれの項目が入力されているかどうかはuseStateで判定されているので、その値が変化する度に定数が変更して有効か無効か判断しています。

const disabledFlg = goalage && age && goalageErr == undefined && ageErr == undefined ? false : true;

Restartボタン

<Button variant="contained" fullWidth size="large" onClick={() => hanbleClickBtn(false)}>RESTART</Button>

TextField OnChange処理

テキストフィールドの値が変わるたびに以下の関数が実行されます。
今回はバリデーション機能は作らずに自作で実装します。

  function handleChangeValue (data: string, type: number) {
    const castData = zenkakuToHankakuNumber(data);
    const result = Number(castData);
    const errMsg = "数値を入力してください";

    if(isNaN(result)) {
      handleValueError(errMsg, type);
    } else {
      handleValidValue(result, type);
    }
  }

まずは全角の数値を半角に変換します。

  function zenkakuToHankakuNumber(data: string) {
    const zenkakuNumberMap: Record<string, string> = {
      '0': '0',
      '1': '1',
      '2': '2',
      '3': '3',
      '4': '4',
      '5': '5',
      '6': '6',
      '7': '7',
      '8': '8',
      '9': '9'
    };
    return data.replace(/[-]/g, str => zenkakuNumberMap[str] || str);
  }

変換した文字列をnumber型に変換します。

const result = Number(castData);

返り値で判定してエラーメッセージを表示するか、正常の数値として扱うか決めています。

エラーメッセージ設定処理

  function handleValueError(msg: string, type: number) {
    if (type === 1) {
      setGoalageErr(msg);
    } else if (type === 2) {
      setAgeErr(msg);
    }
  }

typeで判断してどのテキストフィールドがエラーになるのか判断しています。
この処理をテキストフィールドコンポーネントの中に入れるよに今後リファクタリングしていきます。
正常な数値が設定できてStartボタンを押下したらバッテリーが表示される仕組みにしました。

バッテリー

interface Props {
  batterylevel: number;
}

//バッテリーのカラー定義
const batteryColors = {
  red:  "#CB1B45",
  yellow: "#F7D94C",
  green: "#1B813E",
}

// バッテリーの残量アニメーション
const moveBatteryAnimation = keyframes`
  0% {
    width: 0%;
    background-color: "#fff";
  }
  1% {
    width: 1%;
  }
  25% {
    width: 25%;
  }
  40% {
    width: 40%;
  }
  100% {
    width: 100%;
  }
`;

const fadeAnimation = keyframes`
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
`

// バッテリーのstyle
const BatteryBox = styled('div')({
  width: "100px",
  height: "40px",
  border: "2px solid #FFF",
  boxShadow: "0 0 0 2px #000",
  borderRadius: "3px",
  position: "relative",
  ':after': {
    content: '""',
    width: "5px",
    height: "8px",
    borderRadius: "0 2px 2px 0",
    background: "#000",
    position: "absolute",
    top: "50%",
    right: "-8px",
    transform: "translateY(-50%)",
  },
});

const BatteryEnergy = styled('p')(() => ({
  height: "100%",
  borderRadius: "3px",
  margin: "0",
  animation: `${moveBatteryAnimation} 3s ease-out`,
}));

const BatteryText = styled('span')({
  position: "absolute",
  top: "50%",
  left: "50%",
  transform: "translate(-50%, -50%)",
  webkitTransform: "translate(-50%, -50%)",
  msTransform: "translate(-50%, -50%)",
  opacity: 1,
  animation: `${fadeAnimation} 6s ease-out`,
});

export function Battery({batterylevel}:Props) {
  const energyColor : string = useMemo(() => {
    if(batterylevel <= 25) {
      return batteryColors.red;
    } else if(batterylevel <= 40) {
      return batteryColors.yellow;
    } else {
      return batteryColors.green;
    }
  },[batterylevel])

  return(
    <BatteryBox>
      <BatteryEnergy sx={{width: `${batterylevel}%`, maxWidth: `${batterylevel}%`, backgroundColor: `${energyColor}`}}/>
      <BatteryText>{batterylevel}%</BatteryText>
    </BatteryBox>
  )
}

バッテリーのスタイルはこちらのブログをほぼ使わせていただきました。
https://qiita.com/7note/items/b43f3b394a93fdf2e5b0

アニメーションを表現するためにまずは「keyframe」をインポートします。

import { styled, keyframes } from '@mui/material';

あとはCSSのように記述するだけなので、簡単に表現できました。
まずは定義して、

// バッテリーの残量アニメーション
const moveBatteryAnimation = keyframes`
  0% {
    width: 0%;
    background-color: "#fff";
  }
  1% {
    width: 1%;
  }
  25% {
    width: 25%;
  }
  40% {
    width: 40%;
  }
  100% {
    width: 100%;
  }
`;

animationに設定します。

const BatteryEnergy = styled('p')(() => ({
  height: "100%",
  borderRadius: "3px",
  margin: "0",
  animation: `${moveBatteryAnimation} 3s ease-out`,
}));

styled-commponentのように記述できるので、MUIはとても使いやすいなと感じました。
リファクタリングして少し、改良していきます。

読んでいただきありがとうございました。
よかったら試してみてください。
https://jyudenchi-d0080.web.app/

Discussion