masalibの日記

システム開発、運用と猫の写真ブログです

React Material UI入門3 ログイン画面

Google先生みたいなログイン画面が作りたかった。 それには「Material UI」を使えばいいので Material UIについて勉強しました

前回の続きになります

Material UIとは

GoogleのMaterialデザインをベースに開発された、UIコンポーネントライブラリです。 Google先生のサイトを使った事ある人ならすぐに受け入れてくるのがいい点です。

ログイン画面について

一般的なものです。Google先生のログインはメールアドレスとパスワードが別になっています。 自分が作りたいものは別ではなく一緒のものです。例としてははてなのログイン画面です

f:id:masalib:20201121173526p:plain

実際のログイン画面にはログイン処理および現在のログイン状態が必要なので、複雑です。今回はログイン画面の基礎みたいな所です。この部分はサービスを作る上では必須なのでちゃんと理解したかった。

参考ソースについて

githubに上がっているソースになります

github.com

参考にしたソースはTypeScriptでした自分も同じようにTypeScriptにした方がよかったのですが 他のソースとのレベル間があるので普通のjavascriptにしました。

ソース

クリックすると展開されます(長文なので注意)

import React, { useReducer, useEffect } from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";

import TextField from "@material-ui/core/TextField";
import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent";
import CardActions from "@material-ui/core/CardActions";
import CardHeader from "@material-ui/core/CardHeader";
import Button from "@material-ui/core/Button";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      display: "flex",
      flexWrap: "wrap",
      width: 400,
      margin: `${theme.spacing(0)} auto`
    },
    loginBtn: {
      marginTop: theme.spacing(2),
      flexGrow: 1
    },
    header: {
      textAlign: "center",
      background: "#212121",
      color: "#fff"
    },
    card: {
      marginTop: theme.spacing(10)
    }
  })
);

//state type

/*
type State = {
  username: string
  password:  string
  isButtonDisabled: boolean
  helperText: string
  isError: boolean
};
*/

const initialState: State = {
  username: "",
  password: "",
  isButtonDisabled: true,
  helperText: "",
  isError: false
};

type Action =
  | { type: "setUsername", payload: string }
  | { type: "setPassword", payload: string }
  | { type: "setIsButtonDisabled", payload: boolean }
  | { type: "loginSuccess", payload: string }
  | { type: "loginFailed", payload: string }
  | { type: "setIsError", payload: boolean };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setUsername":
      return {
        ...state,
        username: action.payload
      };
    case "setPassword":
      return {
        ...state,
        password: action.payload
      };
    case "setIsButtonDisabled":
      return {
        ...state,
        isButtonDisabled: action.payload
      };
    case "loginSuccess":
      return {
        ...state,
        helperText: action.payload,
        isError: false
      };
    case "loginFailed":
      return {
        ...state,
        helperText: action.payload,
        isError: true
      };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
   default:
      return state;
  }
};

const Login = () => {
  const classes = useStyles();
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    if (state.username.trim() && state.password.trim()) {
      dispatch({
        type: "setIsButtonDisabled",
        payload: false
      });
    } else {
      dispatch({
        type: "setIsButtonDisabled",
        payload: true
      });
    }
  }, [state.username, state.password]);

  const handleLogin = () => {
    if (state.username === "abc@email.com" && state.password === "password") {
      dispatch({
        type: "loginSuccess",
        payload: "Login Successfully"
      });
    } else {
      dispatch({
        type: "loginFailed",
        payload: "Incorrect username or password"
      });
    }
  };

  const handleKeyPress = (event: React.KeyboardEvent) => {
    if (event.keyCode === 13 || event.which === 13) {
      state.isButtonDisabled || handleLogin();
    }
  };

  const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setUsername",
      payload: event.target.value
    });
  };

  const handlePasswordChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setPassword",
      payload: event.target.value
    });
  };
  return (
    <form className={classes.container} noValidate autoComplete="off">
      <CardHeader className={classes.header} title="Login App" />
      <Card className={classes.card}>
        <CardContent>
          <div>
            <TextField
              error={state.isError}
              fullWidth
              id="username"
              type="email"
              label="Username"
              placeholder="Username"
              margin="normal"
              onChange={handleUsernameChange}
              onKeyPress={handleKeyPress}
            />
            <TextField
              error={state.isError}
              fullWidth
              id="password"
              type="password"
              label="Password"
              placeholder="Password"
              margin="normal"
              helperText={state.helperText}
              onChange={handlePasswordChange}
              onKeyPress={handleKeyPress}
            />
          </div>
        </CardContent>
        <CardActions>
          <Button
            variant="contained"
            size="large"
            color="secondary"
            className={classes.loginBtn}
            onClick={handleLogin}
            disabled={state.isButtonDisabled}
          >
            Login
          </Button>
        </CardActions>
      </Card>
    </form>
  );
};

export default Login;

結果

シンプルなログイン画面です

f:id:masalib:20201121175044p:plain

ソースの解説

長文なのでコピペしただけだと理解できていないので未来の自分用(笑)に解説します。
useReducerやuseEffectについては省略します。

masalib.hatenablog.com

masalib.hatenablog.com

正直にいうとこの部分のためにReact Hooksを勉強しましたwww

typescriptからjavascriptに変更した部分

タイプに関してはカンマをつけました 修正前

type State = {
  username: string
  password:  string
  isButtonDisabled: boolean
  helperText: string
  isError: boolean
};

修正後

type State = {
  username: string,
  password:  string,
  isButtonDisabled: boolean,
  helperText: string,
  isError: boolean,
};

TypeScriptの場合はいらないのかな・・・よくわからないです。

デザインの部分

デザイン部分に関してはMaterial UIのmakeStylesで 作成して指定しています。ここに関しては難しい所はないかと・・・

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      display: "flex",
      flexWrap: "wrap",
      width: 400,
      margin: `${theme.spacing(0)} auto`
    },
    loginBtn: {
      marginTop: theme.spacing(2),
      flexGrow: 1
    },
    header: {
      textAlign: "center",
      background: "#212121",
      color: "#fff"
    },
    card: {
      marginTop: theme.spacing(10)
    }
  })
);

ログインのフォームのHTML部分

ログイン画面はMaterial UIのCardの機能を使って作っています

<form className={classes.container} noValidate autoComplete="off">
  <Card className={classes.card}>
    <CardHeader className={classes.header} title="Login App" />
    <CardContent>
      <div>
        <TextField
          error={state.isError}
          fullWidth
          id="username"
          type="email"
          label="Username"
          placeholder="Username"
          margin="normal"
          onChange={handleUsernameChange}
          onKeyPress={handleKeyPress}
        />
        <TextField
          error={state.isError}
          fullWidth
          id="password"
          type="password"
          label="Password"
          placeholder="Password"
          margin="normal"
          helperText={state.helperText}
          onChange={handlePasswordChange}
          onKeyPress={handleKeyPress}
        />
      </div>
    </CardContent>
    <CardActions>
      <Button
        variant="contained"
        size="large"
        color="secondary"
        className={classes.loginBtn}
        onClick={handleLogin}
        disabled={state.isButtonDisabled}
      >
        Login
      </Button>
    </CardActions>
  </Card>
</form>

Cardの中に

  • CardHeader
  • CardContent
  • CardActions

があります

CardHeader

ログイン部分のヘッダーはシンプルです

<CardHeader className={classes.header} title="Login App" />

上記以外にavatar、action、subheaderなども指定できます

f:id:masalib:20201121190305p:plain

<CardHeader
  avatar={
    <Avatar aria-label="recipe" className={classes.avatar}>
      R
    </Avatar>
  }
  action={
    <IconButton aria-label="settings">
      <MoreVertIcon />
    </IconButton>
  }
  title="Shrimp and Chorizo Paella"
  subheader="September 14, 2016"
/>

CardContent

主軸になるコンテンツを記載します。 ログイン画面なのでログインフォームの本体になります

<CardContent>
  <div>
    <TextField
      error={state.isError}
      fullWidth
      id="username"
      type="email"
      label="Username"
      placeholder="Username"
      margin="normal"
      onChange={handleUsernameChange}
      onKeyPress={handleKeyPress}
    />
    <TextField
      error={state.isError}
      fullWidth
      id="password"
      type="password"
      label="Password"
      placeholder="Password"
      margin="normal"
      helperText={state.helperText}
      onChange={handlePasswordChange}
      onKeyPress={handleKeyPress}
    />
  </div>
</CardContent>

はてなのログインみたいにログインのボタンが複数になる場合は CardContentにログインボタンを配置した方がいいです。
fullWidthでカードの横幅になるみたいなので便利!!

CardActions

カードに対してのアクションを指定します。 ボタンやアイコンやリンクなどを指定できます。またもっと見るみたいな行為もできる。
アクションは1行しか表現できないので複数行の場合はコンテンツで記載するもよう・・・

アイコンの場合

f:id:masalib:20201121185017p:plain

<CardActions disableSpacing>
  <IconButton aria-label="add to favorites">
    <FavoriteIcon />
  </IconButton>
  <IconButton aria-label="share">
    <ShareIcon />
  </IconButton>
  <IconButton
    className={clsx(classes.expand, {
      [classes.expandOpen]: expanded,
    })}
    onClick={handleExpandClick}
    aria-expanded={expanded}
    aria-label="show more"
  >
    <ExpandMoreIcon />
  </IconButton>
</CardActions>

リンクの場合

f:id:masalib:20201121185307p:plain

<CardActions>
  <Button size="small" color="primary">
    Share
  </Button>
  <Button size="small" color="primary">
    Learn More
  </Button>
</CardActions>

ボタンの場合(リンクとボタン違いはvariant="contained")

f:id:masalib:20201121190025p:plain

<CardActions>
  <Button size="small" variant="contained" color="primary">
    Share
  </Button>
  <Button size="small" variant="contained" color="primary">
    Learn More
  </Button>
</CardActions>

ログインのフォームの処理部分

4つの処理で構成されています

  • 入力された値を保存する処理
  • ボタンを有効化する処理
  • ログイン処理

値を保存する部分はユーザー名とパスワードがありますが、ほぼ同じなので1つだけ記載します

入力された値を保存する処理

ユーザー名が入力される(onChange)とhandleUsernameChangeの関数が起動します。

const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (
  event
) => {
  dispatch({
    type: "setUsername",
    payload: event.target.value
  });
};

その値をuseReducerで作成したdispatchの関数に渡します。 dispatchの関数はreducerという関数をもとに作っています

const [state, dispatch] = useReducer(reducer, initialState);

そのreducerは状態遷移の関数です

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setUsername":
      return {
        ...state,
        username: action.payload
      };
    case "setPassword":
      return {
        ...state,
        password: action.payload
      };
    case "setIsButtonDisabled":
      return {
        ...state,
        isButtonDisabled: action.payload
      };
    case "loginSuccess":
      return {
        ...state,
        helperText: action.payload,
        isError: false
      };
    case "loginFailed":
      return {
        ...state,
        helperText: action.payload,
        isError: true
      };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
    default:
      return state;
  }
};

typeがsetUsernameなので

case "setUsername":
  return {
    ...state,
    username: action.payload
  };

渡ってきたstateのusernameにaction.payloadを上書きしています。 そのほかの値は変更しません。

ちなみに「: React.ChangeEventHandler」の部分はTypescriptの部分なので・・・javascriptなら下記でも動きました。 eventはeでも動きます

const handleUsernameChange = (
  event
) => {
  dispatch({
    type: "setUsername",
    payload: event.target.value
  });
};

ボタンを有効化する処理

ユーザー名とパスワード名が入力されるとonChangeが起動されてhandleUsernameChangeまたはhandlePasswordChangeのonChangeが起動されます。 起動されるとstate.usernameとstate.passwordの値が変更されます。 変更されるとuseEffectが起動されます。

useEffect(() => {
  if (state.username.trim() && state.password.trim()) {
    dispatch({
      type: "setIsButtonDisabled",
      payload: false
    });
  } else {
    dispatch({
      type: "setIsButtonDisabled",
      payload: true
    });
  }
  console.log("useEffect");
}, [state.username, state.password]);

どちらも入力されていた場合にdispatchが起動される

dispatch({
  type: "setIsButtonDisabled",
  payload: false
});

reducerのsetIsButtonDisabledが起動されてボタンの有効化、無効化を変化させている

case "setIsButtonDisabled":
  return {
    ...state,
    isButtonDisabled: action.payload
  };

ログイン処理

ログインのボタンが押されるとhandleLoginが起動される。 この部分は本来は認証させる部分なので本当はもっと複雑。

const handleLogin = () => {
  if (state.username === "abc@email.com" && state.password === "password") {
    dispatch({
      type: "loginSuccess",
      payload: "Login Successfully"
    });
  } else {
    dispatch({
      type: "loginFailed",
      payload: "Incorrect username or password"
    });
  }
};

ログインが成功するとloginSuccessのdispatchが起動されて画面にも反映される。 失敗するとloginFailedが起動される。

ちなみにユーザー名はパスワードをTextFieldにいる状態でEnterキーを入力するとonKeyPressで設定されているhandleKeyPressが起動される。 (その他のキーでも起動するのでが・・・)

const handleKeyPress = (event: React.KeyboardEvent) => {
  if (event.keyCode === 13 || event.which === 13) {
    console.log("enter key");
    state.isButtonDisabled || handleLogin();
  }
};

Enterキーのコードの13だった場合にログインの関数のhandleLoginを起動しています。

このEnterキーの処理はいるのかと聞かれると・・・といわれる・・・う~ん、他のサイトでもやっているみたい。
必須はないが、あるとUI的にはいいっぽい。

感想

これでログイン画面の基礎ができた。 FireBaseにログインさせる場合でも普通の認証でも使うのでよかったと思う。 Cardの部分についても勉強できたのでよかった