タイムリミットを表示する
はじめに、ブラウザにタイムリミットを表示します。
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');
:root {
--text-color: #333;
--bg-color: #ffdf2b;
}
import React from 'react';
import styled from 'styled-components'
import TimeDisplay from './components/TimeDisplay'
const GlobalStyle = createGlobalStyle`
body {
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 4vmin;
margin: 0;
height: 100vh;
width: 100%;
}
`;
const StyledApp = styled.div`
`
const App = () => {
return (
<>
<GlobalStyle />
<StyledApp>
<TimeDisplay
className="count-down-timer"
time={{
hour: 0,
min: 0,
sec: 0
}}
delimiter=":"
fontSize="2.8em"
/>
</StyledApp>
</>
)
}
export default App
タイムリミットの初期状態は「00:00:00」とします。
TimeDisplayコンポーネントは、タイムリミットを表示する役割を持ちます。TimeDisplayタイムリミットについては【React】時刻や生年月日などをセレクトボックスで選択するピッカーを作るをご覧ください。
ブラウザには次のようにタイムリミットが表示されます。
タイムリミットをタイムピッカーで選択する
タイムピッカーを表示し、ユーザーがそのタイムピッカーで選択した値がそのままタイムリミットの時・分・秒に反映されるようにします。
// 省略
import Spacer from './components/Spacer'
import ItemPickers from './components/ItemPickers'
const TIMES = {
hour: [...Array(60)].map((u, i) => i),
min: [...Array(60)].map((u, i) => i),
sec: [...Array(60)].map((u, i) => i),
}
const GlobalStyle = createGlobalStyle`
body {
// 省略
}
select, option {
font-family: 'Share Tech Mono', monospace;
}
`;
// 省略
const App = () => {
const [selectItems, setSelectItems] = useState(() => {
const newItems = {}
Object.keys(TIMES).forEach((name, i) => {
newItems[name] = TIMES[name][0]
})
return newItems
})
const handleChange = useCallback((e) => {
setSelectItems(p => ({
...p,
[e.target.id]: Number(e.target.value)
}))
}, [])
// タイムリミットを保存するステート
const [timeLimit, setTimeLimit] = useState(selectItems);
// 値をゼロ埋めする関数
const zeroPaddingNum = useCallback((num) => {
return String(num).padStart(2, "0")
}, [])
// ピッカーで選択した値をそのままタイムリミットとして反映する
useEffect(() => {
setTimeLimit({
hour: zeroPaddingNum(selectItems.hour),
min: zeroPaddingNum(selectItems.min),
sec: zeroPaddingNum(selectItems.sec)
})
}, [selectItems])
return (
<>
<GlobalStyle />
<StyledApp>
<ItemPickers
className="time-picker"
items={TIMES}
handleChange={handleChange}
/>
<Spacer size="1.6em" />
<TimeDisplay
// 省略
time={timeLimit}
/>
</StyledApp>
</>
)
}
export default App
ItemPickersコンポーネントについては【React】時刻や生年月日などをセレクトボックスで選択するピッカーを作るをご覧ください。
新たにtimeLimit
ステートを定義し、このステートでタイムリミットを管理します。初期値は、ピッカーで選択した値を保存しているselectItems
ステートです。
ピッカーで選択した値は、ゼロ埋めしてからtimeLimit
ステートに上書きします。
useEffect(() => {
setTimeLimit({
hour: zeroPaddingNum(selectItems.hour),
min: zeroPaddingNum(selectItems.min),
sec: zeroPaddingNum(selectItems.sec)
})
}, [selectItems])
このtimeLimit
ステートをTimeDisplayコンポーネントのtimeプロパティに設定することで、次のようにピッカーで選択した値がそのままタイムリミットに反映されるようになります。
カウントダウンを開始する
スタートボタンを押すとカウントダウンが開始されるようにします。
// 省略
import Button from './components/Button'
const App = () => {
// 省略
// カウントダウンタイマーがスタートしているかどうか
const [isStart, setIsStart] = useState(false)
// setIntervalメソッドが返す一意に識別するIDを保存するRef
const intervalID = useRef(null);
// タイマーをスタートする関数
const startTime = useCallback(() => {
intervalID.current = setInterval(() => tick(), 1000);
setIsStart(true)
})
// 時を刻む関数
const tick = useCallback(() => {
setTimeLimit((prevTimeLimit) => {
const newTimeLimit = Object.assign({}, prevTimeLimit);
const { hour, min, sec } = newTimeLimit;
// 「時」が1時間以上あるときに「分」と「秒」が0以下になったら、「時」を1つ減らして、「分」を60に戻す
if (newTimeLimit.hour > 0 && min <= 0 && sec <= 0) {
newTimeLimit.hour -= 1;
newTimeLimit.min = 60;
}
// 「分」が1分以上あるときに「秒」が0以下になったら、「分」を1つ減らして、「秒」を60に戻す
if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
newTimeLimit.min -= 1;
newTimeLimit.sec = 60;
}
// 「秒」を1つ減らす
newTimeLimit.sec -= 1;
return {
hour: zeroPaddingNum(newTimeLimit.hour),
min: zeroPaddingNum(newTimeLimit.min),
sec: zeroPaddingNum(newTimeLimit.sec)
};
});
})
return (
<>
<GlobalStyle />
<StyledApp>
{/* 省略 */}
<TimeDisplay
// 省略
/>
<Spacer size="1.6em" />
<Button
className="start-button"
onClick={startTime}
disabled={isStart ? true : false}
>
start
</Button>
</StyledApp>
</>
)
}
export default App
※Buttonコンポーネントについては【React】ボタンを作るをご覧ください。
カウントダウンは、startTime関数が実行されると行われます。
startTime関数が実行されると、setIntervalメソッドで1秒毎にtick関数を実行させ、その中でtimeLimit
ステートを更新させてカウントダウンさせます。
このstartTime関数をButtonコンポーネントのonClick属性に設定すると、スタートボタンが押されるとカウントダウンが開始されるようになります。
また、disabled属性を
<Button
// 省略
disabled={isStart ? true : false}
>
のようにすることで、isStart
がtrueのとき、つまりカウントダウンが開始されているときはボタンを押しても反応させないようにします。反対に、falseであればカウントダウン前ということなので反応させるようにします。
カウントダウンの仕組みについてですが、時間、分、秒を1つ減らすタイミングは次の通りです。
時刻 | 1減らすタイミング |
---|---|
時間 | 1時間以上あるときに、分と秒が0以下になったとき |
分 | 1分以上あるときに、秒が0以下になったとき |
秒 | タイムアップまで常時 |
また、分と秒は次の条件に当てはまる間は、「00」になったあとに「60」に戻す必要があります。
時刻 | 条件 |
---|---|
分 | 1時間以上ある間 |
秒 | 1分以上ある間 |
カウントダウンを一時停止する
ストップボタンを表示して、カウントダウン中にストップボタンを押すとカウントダウンが一時停止されるようにします。
// 省略
const App = () => {
// 省略
// カウントダウンがストップしていればtrue
const [isStop, setIsStop] = useState(false)
const startTime = useCallback(() => {
// 省略
setIsStop(false)
})
// カウントダウンを一時停止する関数
const stopTime = useCallback(() => {
clearInterval(intervalID.current);
setIsStop(true)
setIsStart(false)
});
// 省略
return (
<>
<GlobalStyle />
<StyledApp>
{/* 省略 */}
<div className="buttons">
<Button
className="start-button"
// 省略
>
start
</Button>
<Spacer
size=".65em"
horizontal={true}
/>
<Button
className="stop-button"
onClick={stopTime}
disabled={isStart ? false : true}
>
stop
</Button>
</div>
</StyledApp>
</>
)
}
export default App
カウントダウンの一時停止はstopTime関数で行います。この関数は、clearIntervalメソッドでsetIntervalメソッドの実行を停止します。この際に必要となるのが先ほどのintervalID
なのです。
この関数をストップボタン.stop-button
のonClick属性に設定することで、ボタンが押されたときにstopTime関数が実行されてカウントダウンが一時停止されるようになります。
00:00:00になったらタイムアップする
タイムリミットが「00:00:00」になったら、タイムアップということですので、まずカウントダウンを停止させます。
// 省略
const App = () => {
// 省略
// タイムアップしていればtrue
const [isTimeUp, setIsTimeUp] = useState(false)
const startTime = useCallback(() => {
// 省略
setIsTimeUp(false)
})
// 省略
const tick = useCallback(() => {
setTimeLimit((prevTimeLimit) => {
// 省略
// カウントダウン中に00:00:00になったらタイムアップ
if (hour <= 0 && min <= 0 && sec <= 0) {
stopTime();
setIsTimeUp(true)
return newTimeLimit;
}
// 省略
});
})
return (
// 省略
)
}
export default App
タイムアップした後はstopTime関数によってisStart
ステートがfalseに切り替わり、スタートボタンが再び有効になります。
再び有効になったスタートボタンを押したときは、ピッカーで選択した値で再びカウントダウンが始まるようにします。
// 省略
const App = () => {
// 省略
const tick = useCallback(() => {
// 省略
})
// タイムアップ後にスタートボタンを押したときは、選択したタイムからカウントダウンする
useEffect(() => {
// タイムアップ中且つスタートボタンを押した
if(isStart && isTimeUp) {
setTimeLimit({
hour: zeroPaddingNum(selectItems.hour),
min: zeroPaddingNum(selectItems.min),
sec: zeroPaddingNum(selectItems.sec)
})
setIsTimeUp(false)
}
}, [isStart])// スタートボタンを押したときに実行
return (
// 省略
)
}
export default App
isTimeUp
ステートはタイムアップしたかどうかを示し、このステートを利用してダイアログでタイムアップしたことを知らせるといったことができます。
タイムリミットをリセットする
リセットボタンを押すと、カウントダウン中や一時停止中に関わらずタイムリミットをピッカーで選択した値にリセットします。
// 省略
const App = () => {
// 省略
// カウントダウンがリセットされたらtrue
const [isReset, setIsReset] = useState(false)
const startTime = useCallback(() => {
// 省略
setIsReset(false)
})
// タイムリミットをリセットする関数
const resetTime = useCallback(() => {
clearInterval(intervalID.current);
// タイムピッカーで選択中の値に戻す
setTimeLimit({
hour: zeroPaddingNum(selectItems.hour),
min: zeroPaddingNum(selectItems.min),
sec: zeroPaddingNum(selectItems.sec)
})
setIsReset(true)
setIsStart(false)
setIsStop(false)
setIsTimeUp(false)
})
// 省略
return (
<>
<GlobalStyle />
<StyledApp>
{/* 省略 */}
<div className="buttons">
{/* 省略 */}
<Spacer
size=".65em"
horizontal={true}
/>
<Button
className="reset-button"
onClick={resetTime}
disabled={isStart || isStop && !isTimeUp && !isReset ? false : true}
>
reset
</Button>
</div>
</StyledApp>
</>
)
}
export default App
タイムリミットのリセットはresetTime関数で行います。resetTime関数が実行されると、clearIntervalメソッドでsetIntervalメソッドの実行を停止し、タイムリミットをピッカーで選択したときの値に戻します。これがリセットということです。
このresetTime関数をリセットボタン.reset-button
のonClick属性に設定することで、ボタンが押されるとresetTime関数が実行されてタイムリミットがリセットされます。
また、リセットボタンはdisabled属性を
<Button
// 省略
disabled={
isStart ||
isStop &&
!isTimeUp &&
!isReset ? false : true
}
>
のように設定することで、
- カウントダウン中または一時停止中
- リセットしていない
ときのみリセットボタンを押せるようにしています。
ロジックとビューを分離する
ロジックで肥大化したAppコンポーネントのロジックとビューを分離します。
はじめに、Appコンポーネントからロジックを抽出し、それを内容とするカスタムフックを作成します。
impot { useState, useRef, useCallback, useEffect } from 'react'
const useCountDownTimer = (time) => {
const [timeLimit, setTimeLimit] = useState(time);
const [isStart, setIsStart] = useState(false)
const [isStop, setIsStop] = useState(false)
const [isReset, setIsReset] = useState(false)
const [isTimeUp, setIsTimeUp] = useState(false)
const intervalID = useRef(null);
const zeroPaddingNum = useCallback((num) => {
return String(num).padStart(2, "0")
}, [])
const startTime = useCallback(() => {
intervalID.current = setInterval(() => tick(), 1000);
setIsStart(true)
setIsStop(false)
setIsTimeUp(false)
setIsReset(false)
});
const stopTime = useCallback(() => {
clearInterval(intervalID.current);
setIsStop(true)
setIsStart(false)
});
const resetTime = useCallback(() => {
clearInterval(intervalID.current);
setTimeLimit({
hour: zeroPaddingNum(time.hour),
min: zeroPaddingNum(time.min),
sec: zeroPaddingNum(time.sec)
})
setIsReset(true)
setIsStart(false)
setIsStop(false)
setIsTimeUp(false)
})
const tick = useCallback(() => {
setTimeLimit((prevTimeLimit) => {
const newTimeLimit = Object.assign({}, prevTimeLimit);
const { hour, min, sec } = newTimeLimit;
if (hour <= 0 && min <= 0 && sec <= 0) {
stopTime();
setIsTimeUp(true)
return newTimeLimit;
}
if (newTimeLimit.hour > 0 && min <= 0 && sec <= 0) {
newTimeLimit.hour -= 1;
newTimeLimit.min = 60;
}
if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
newTimeLimit.min -= 1;
newTimeLimit.sec = 60;
}
newTimeLimit.sec -= 1;
return {
hour: zeroPaddingNum(newTimeLimit.hour),
min: zeroPaddingNum(newTimeLimit.min),
sec: zeroPaddingNum(newTimeLimit.sec)
};
});
})
// ピッカーで選択した値をそのままタイムリミットとして反映する
useEffect(() => {
setTimeLimit({
hour: zeroPaddingNum(time.hour),
min: zeroPaddingNum(time.min),
sec: zeroPaddingNum(time.sec)
})
}, [time])
// タイムアップした後にスタートボタンを押したときに選択したタイムからカウントダウンする
useEffect(() => {
if(isStart && isTimeUp) {
setTimeLimit({
hour: zeroPaddingNum(time.hour),
min: zeroPaddingNum(time.min),
sec: zeroPaddingNum(time.sec)
})
setIsTimeUp(false)
}
}, [isStart])
return [
timeLimit,
startTime,
stopTime,
resetTime,
{ isStart, isStop, isTimeUp, isReset }
];
};
export default useCountDownTimer
そして、作成したカスタムフックをAppコンポーネント内で呼び出します。
// 省略
import useCountDownTimer from './hooks/useCountDownTimer'
const App = () => {
// 省略
const [
time,
startTime,
stopTime,
resetTime,
{ isStart, isStop, isTimeUp, isReset }
] = useCountDownTimer(
selectItems
);
return (
// 省略
)
}
export default App
これでAppコンポーネントの見通しが良くなりました。
DEMO
下記で実際の動作を確認できます。