はじめに
この記事は「Reactのチュートリアルを終えて、何かを作ってみたい」という読者を想定しています。
分からない部分が出た場合はReactの公式ドキュメントに立ち返りましょう。
material-uiの公式ドキュメントも要チェック
前にReact+Material-UIで作ったもの
今回作ったもの
typeee!
英単語のタイピング速度を測定できるアプリです。ランダムな英単語20個を入力したタイムからCPM(1分に入力できる文字数)を計算し、評価します。
是非、使ってツイートしてみてください。とても嬉しくなります。
特徴的な機能としては、
- タイムとCPMをリアルタイム表示している
- 「もう一度遊ぶ」ボタンでページをリロードすることなく初期状態に戻している
が挙げられます。
戦略
ReactコンポーネントにはMaterial-UIを使い、保持するstateをReactのuseStateで実装します。今回はstateの数が多く、子コンポーネントに受け流す記述が多く発生しました。これ以上コンポーネントを増やす場合はReduxを使うかContext APIを使うと思います。
英単語のデータはクジラWeb APIの頻出英単語2000というところからcsv形式で入手したものをjson形式に変換して静的に格納しています。アプリを作る段階ではaxiosを使ってAPI叩いてどうのこうのしようと思っていたのですが、今回のアプリでは単語の名前データは使いまわしでOKなのでやめました。
ちなみに、HTTPリクエストでAPIを使ってテストしたい場合はPostmanというアプリをおすすめします。
タイピング部分をどうするか
Reactには合成イベントというものが用意されています。コンポーネントに対するユーザーの入力に反応して何かやってくれるものです。ボタンで使うonClick
やフォームで使うonChange
もこの一種ですね。キーボードの文字入力に反応するonKeyPress
というのも用意されていて、今回はこれを使用します。
onKeyPress
では押されたキーの情報を保持してくれて、event.hogehoge
で参照できます(hogehogeに使いたいプロパティの名前を入れる)。下のようにReact公式にプロパティ一覧が載っているのでみてください。
今回はkey
という、押されたキーの文字列を返してくれるプロパティを使います。
component, containerの洗い出し
component
- 説明蘭: 説明文が書かれただけのcomponent。親からpropsを受け取ったりとかもしません。
- 終了時のモーダル: タイピングが終了したと同時に開くモーダル。親から結果の情報などをいろいろ受け取る。
留意
本来、このタイピング実行部分はコンポーネントにするべきですが、今回はContanierにそのまま記述しました。なぜならば、これをコンポーネントにした場合、コンポーネント間でのstateの受け渡しが多いためにreactのみでは辛いからです。もしコンポーネント化するならばcontext APIかReduxを導入するでしょう。
container
- Home: 1画面しかないですが、Homeです。ここにタイピング実行エリアの記述をしています。
stateの洗い出し
-
typingString
: string, 入力する文字列全体。useEffect
内で、refresh
が更新されるたびに英単語を20個ランダムに並べて生成している。 -
loading
: boolean,useEffect
で文字列を生成しているときにtrueとなり、生成できたらfalse。ただ、今回はデータをHTTP通信で取得するわけではないから、必須ではない。 -
currentIndex
: number, 文字列の中でいまどこを入力しているのかを追うためのもの。これがtypingString以上になったときに終了してモーダルを開く。 -
isMisstype
: boolean, 現在入力している文字が1度でもミスタイプされたときにtrue
となり、正しく入力されるとfalse
になる。文字を赤くするかどうかの判別に使う。 -
missCount
: number, ミスタイプした回数。 -
finished
: boolean, タイプが終わったらtrue
。 -
started
: boolean, タイピングが始まったらtrue
。 -
timeOfTyping
: number, タイムにかかっている時間(ms)。 -
modalOpen
: boolean,true
の場合モーダルを開く。 -
refresh
: number, ランダムな数字が入る。モーダルの「もう一度」ボタンを押して次の英単語を取得するときに使用する。useEffect
の第2引数に指定しているため、これが更新されるとuseEffect
内部処理が走る。
インストール
create react app
npx create-react-app react-typeee
cd react-typeee
@material-ui/core
// npm を使う場合
npm install @material-ui/core
// yarn を使う場合
yarn add @material-ui/core
バージョン情報
- react: 16.13.1
- @material-ui/core: 4.9.9
ファイル構成
コンポーネントの実装
Discription
import React from 'react';
import {makeStyles} from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography';
const useStyles = makeStyles((theme) => ({
root:{
width:"80%",
margin:"30px",
},
content:{
fontSize: "16px",
},
}));
function Discription() {
const classes= useStyles();
return (
<div className={classes.root}>
<Typography className={classes.content}>
<b>あなたの英単語タイピング速度はどのくらいでしょう?</b><br />
CPM(Characters Per Minute):1分間あたりに入力できる文字数から判定します。<br />
単語の間はスペースを入力してください。<br />
下の英文のどこかをクリックしたあとにスタートできます。
</Typography>
</div>
)
}
export default Discription;
SuccessModal
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Modal from '@material-ui/core/Modal';
import Backdrop from '@material-ui/core/Backdrop';
import Fade from '@material-ui/core/Fade';
import Box from '@material-ui/core/Box';
import Button from '@material-ui/core/Button';
import logo from '.././images/typeee-logo.svg';
import {timeFormatting, cpmToRank, cpmToDiscription} from '../util/util';
import Typography from '@material-ui/core/Typography';
import {TwitterShareButton, TwitterIcon} from 'react-share';
const useStyles = makeStyles((theme) => ({
modal: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
paper: {
backgroundColor: theme.palette.background.paper,
textAlign:'center',
border: '2px solid #000',
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
},
logo:{
width: "150px",
marginBottom: "20px"
},
content:{
fontSize:"20px",
fontFamily: "monospace",
}
}));
function SuccessModal(props) {
const classes = useStyles();
const result = props.result;
const cpm = (result.charLength/ result.timeOfTyping * 1000 * 60).toFixed(0);
return (
<div>
<Modal
aria-labelledby="transition-modal-title"
aria-describedby="transition-modal-description"
className={classes.modal}
open={props.modalOpen}
onClose={props.modalClose}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{
timeout: 500,
}}
>
<Fade in={props.modalOpen}>
<div className={classes.paper}>
<img src={logo} alt="logo" className={classes.logo} />
<div id="transition-modal-description">
<Typography className={classes.content}>
文字数: {result.charLength}<br />
タイム: {timeFormatting(result.timeOfTyping)}<br />
精度: {(result.charLength/(result.charLength+result.missCount) * 100).toFixed(1)}%<br />
CPM(1分間あたりの入力文字数): {cpm}<br />
</Typography>
<Box marginTop="20px">
<Typography style={{display: "inline", fontSize:"20px"}}>あなたは・・・</Typography>
<Typography style={{display: "inline", fontSize:"28px", fontWeight:"bold"}}>{cpmToRank(cpm)}</Typography>
</Box>
<Box marginTop="20px" maxWidth="500px" >
<Typography style={{fontSize:"18px"}}> {cpmToDiscription(cpm)}</Typography>
</Box>
<Box display="flex" justifyContent="center" m={4}>
<Button onClick={props.refreshAll} color="primary" variant="contained">もう一度</Button>
<Box marginLeft="50px">
<TwitterShareButton
url="https://shintaro-hirose.github.io/typeee/"
title={`typeee!でタイピング速度を計測しました! ${result.charLength}文字, ${timeFormatting(result.timeOfTyping)}秒, 精度 ${(result.charLength/(result.charLength+result.missCount) * 100).toFixed(1)} %, ${cpm} CPM, 評価は "${cpmToRank(cpm)}" でした。`}
hashtags={["typeee"]}>
<TwitterIcon size={40} round={true}/>
</TwitterShareButton>
</Box>
</Box>
</div>
</div>
</Fade>
</Modal>
</div>
);
}
export default SuccessModal;
ポイント
- 元となるコンポーネントはMaterial-UIの公式ドキュメントのModalからコピーしました。
- 親からpropsで
result
,modalOpen
,refreshAll
というプロパティを受け取っています。 - 「もう一度」ボタンの
onClick
で親のrefreshAll
が呼び出され、親のrefreshAll
ではすべてのstateが初期化されるのです。
コンテナの実装
import React,{useState, useEffect, useRef} from 'react'
import {makeStyles} from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';
import logo from '.././images/typeee-logo.svg';
import SuccessModal from '../components/SuccessModal';
import Discription from '../components/Discription';
import {timeFormatting} from '../util/util'
const {dictionary} = require('../util/dictionary');
const useStyles = makeStyles(() => ({
logo:{
width: "400px",
},
inputBox:{
padding: "30px",
marginBottom: "30px",
},
greenFont:{
color:"#689f38",
display:"inline",
fontFamily:"Times New Roman",
fontSize: "50px"
},
redFont:{
backgroundColor:"#e0e0e0",
color:"red",
display:"inline",
fontFamily:"Times New Roman",
fontSize: "50px"
},
greyFont:{
color:"grey",
display:"inline",
fontFamily:"Times New Roman",
fontSize: "50px"
},
blackFont:{
backgroundColor:"#e0e0e0",
display:"inline",
fontFamily:"Times New Roman",
fontSize: "50px"
},
stats:{
display: "inline",
fontSize: "20px",
margin: "0 30px",
},
rights:{
fontSize: "20px",
padding:"20px",
}
}));
function Home() {
const classes = useStyles();
const [typingString, setTypingString] = useState("");
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [isMisstype, setIsMisstype] = useState(false);
const [missCount, setMissCount] = useState(0);
const [finished, setFinished] = useState(false);
const [started, setStarted] = useState(false);
const [timeOfTyping, setTimeOfTyping] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const [refresh, setRefresh] = useState("");
let timer = useRef(null);
useEffect(() => {
setLoading(true)
let ts = '';
for (var i=0; i<20; i++){
let word = dictionary[Math.floor( Math.random() * (2000) )].letter;
ts += word + ' '
}
let newTypingString = ts.slice(0,-1)
setTypingString(newTypingString);
setLoading(false);
}, [refresh])
const handleKeyPress = (e) => {
if(finished) return;
if(!started){
setStarted(true);
const startTime = new Date().getTime();
timer.current = setInterval(() => {
setTimeOfTyping((new Date().getTime()) - startTime)
}, 10)
}
if(e.key === typingString[currentIndex]){
setIsMisstype(false);
setCurrentIndex(currentIndex + 1);
if(currentIndex+1 >= typingString.length){
clearInterval(timer.current);
setFinished(true);
setModalOpen(true);
}
} else {
setIsMisstype(true);
setMissCount(missCount + 1)
}
}
const refreshAll = () => {
setLoading(false);
setCurrentIndex(0);
setIsMisstype(false);
setMissCount(0);
setFinished(false);
setStarted(false);
setTimeOfTyping(0);
setModalOpen(false);
setRefresh(Math.random())
}
return (
loading ? (
<p></p>
) : (
<div align="center" >
<img src={logo} alt="logo" className={classes.logo} />
<Discription />
<div onKeyPress={(e) => handleKeyPress(e)} tabIndex={0} className={classes.inputBox}>
<Typography className={classes.greenFont}>
{typingString.slice(0,currentIndex)}
</Typography>
{isMisstype ? (
<Typography className={classes.redFont}>
{typingString[currentIndex]}
</Typography>
) : (
<Typography className={classes.blackFont}>
{typingString[currentIndex]}
</Typography>
)}
<Typography className={classes.greyFont}>
{typingString.slice(currentIndex+1,typingString.length)}
</Typography>
</div>
<Box display="flex" justifyContent="center">
<Typography className={classes.stats}>
ミスタイプ: {missCount}回
</Typography>
<Typography className={classes.stats}>
タイム: {timeFormatting(timeOfTyping)}
</Typography>
</Box>
<Box marginBottom="50px">
<Typography className={classes.stats}>
CPM: {(currentIndex === 0) ? "-" : (currentIndex / timeOfTyping * 1000 * 60).toFixed(0)}
</Typography>
</Box>
<SuccessModal
result={{
timeOfTyping,
missCount,
charLength: typingString.length
}}
modalOpen={modalOpen}
modalClose={() => setModalOpen(false)}
refreshAll={refreshAll}
/>
</div>
)
)
}
export default Home;
useEffectについて
-
refresh
という値が更新されるたびに処理を行うようにしています。 -
dictionary
という、英単語の情報が2000個入った配列からランダムで20個の英単語を取り出し、文字列としてtypingString
にセットしています。
タイピング部分の表示について
- 現在タイピング中の文字、すでにタイピングし終わった文字、まだタイピングしていない文字の3種類にわけて、それらを結合しています。
handleKeyPressについて
-
e.key
で入力されたキーの文字列を取得しています - 正しい場合、
currentIndex
を進める、誤りだった場合、missCount
を上げてisMisstype
をtrue
にします。
タイムのリアルタイム表示について
- 初めてキーが押されたときに
setInterval
が実行され、10msごとに現在時間からスタート時間を差し引いたものを返します。(10msごとに10あげるカウントを用意するやり方だとタイムがずれる場合があるので避けます。) - finish時に
clearInterval
でintervalを止めます。 -
関数コンポーネントで
setInterval
とclearInterval
を使う場合、注意が必要です。はじめにlet timer = useRef(null);
とすることで、共通のtimerを指定することができます。 - useRefに関する警告メッセージが発生しているので、この使い方はよくないかもしれません。これについては調べてみます。
「もう一度」ボタンを押したときのrefreshAll
について
もう一度遊ぶ場合、同じページをロードさせてstateや文字列を初期化することはできます。しかし、それはDOMを再度1からレンダリングすることになるのでReactの利点を無視した実装となります。
そのような実装にせず、コンテナ内でstateを初期化し、useEffectで新しい文字列を取得するようにしました。
App.js
import React from 'react';
import './App.css';
import Home from './containers/Home'
function App() {
return (
<div className="App" >
<div className="container">
<Home />
</div>
<footer>© 2020 Shintaro Hirose</footer>
</div>
);
}
export default App;
App.css
.App {
text-align: center;
background-color: #eeeeee;
}
.container {
max-width: 800px;
margin: 0 auto;
padding-top: 20px;
}
footer {
background-color: #bdbdbd;
font-size: 25px;
padding: 20px;
}
以上で完成です!
utilで関数を定義しているので、それはgithubのソースコードを確認してください
おまけ1 Twitterのシェアアイコンを追加
結果をTwitterで共有しやすくするためにTwitterのシェアボタンをモーダルに追加しました。
react-shareをインストールしてアイコンを設置しました。公式ドキュメントに従えば迷うことはないと思います。
おまけ2 Github pagesで公開する
こちらの記事を参考に、gh-pagesでサイトを公開しました。
https://shintaro-hirose.github.io/typeee/
今後もReactで簡単なアプリを作っていきます!