React와 SVG로 Circular 타이머 구현하기

시작하기

svg와 react, moment를 이용해서 다음 이미지와 같지는 않더라고 흡사한 타이머를 구현해 보도록 하겠습니다.

클론할 타이머 디자인

설치하기

1
2
3
create-react-app timer --typescript
cd timer
npm i moment @types/moment styled-components @types/styled-components @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome

create-react-app으로 프로젝트를 생성합니다.

컴포넌트 생성

Timer.tsx
1
2
3
4
5
6
7
8
9
import React from 'react';

const Timer:React.FC = () => {
return <div>
<p>2:35</p>
<p>Timer</p>
</div>
}
export default Timer;

실제 타이머가 나올 컴포넌트입니다.
시간과 이름그리고 그래프가 나올 중요한 컴포넌트 입니다.

App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import Timer from './Timer';

const App: React.FC = () => {
return (
<div className="App">
<header>
<span>Back</span>
<span>Time</span>
</header>
<Timer />
<div>
<button type="button">일시정지</button>
<button type="button">기록</button>
</div>
</div>
);
}

export default App;

기본 레이아웃이 생성했습니다.
라우터 기능은 따로 구현하지는 않지만 ui를 보여주기만 합니다.
타이머를 멈추고 재생기능을 할 버튼 한가지를 생성하고
기록버튼도 ui를 보여주기만 합니다.

스타일 적용

index.css
1
2
3
4
5
6
7
body {
display: flex;
align-items: center;
justify-content:center;
width: 100vw;
height: 100vh;
}

가운데로 옮겨줍니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, {useState, useEffect} from 'react';
import styled from 'styled-components';

const TimerStyle = styled.div`
position: relative;
`
const SvgWrapper = styled.svg`
transform: scale(0.85);
`
const Timer: React.FC = () => {
const size = 100;
const center = size / 2;
const strokeWidth = 10;
const radius = center - strokeWidth / 2;

return <TimerStyle>
<SvgWrapper viewBox={`0 0 ${size} ${size}`} >
<defs>
<linearGradient id="gradient">
<stop offset="0%" stopColor="rgb(245,221,100)" />
<stop offset="100%" stopColor="rgb(234,111,37)" />
</linearGradient>
</defs>
<circle cx={center} cy={center} r={center} fill='rgb(20,13,4)' stroke='none' />
<circle cx={center} cy={center} r={center - strokeWidth} fill='#000' stroke='none' />
<path
strokeLinecap="round"
strokeWidth={strokeWidth}
stroke="url(#gradient)"
fill='none'
/>
<text x={center - 28} y={center + 2} fill="#fff" fontSize="20" fontWeight="900">02:35</text>
<text x={center - 20} y={center + 15} fill="rgb(108,108,108)" fontSize="8">Watch Timer</text>
</SvgWrapper>
</TimerStyle>
}
export default Timer;

타이머 컴포넌트의 스타일을 넣어주면서 svg를 그려줍니다.

스타일 적용

SVG Timer Circle 적용

원형 그래프를 그리는 기능을 추가해줍니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';

const TimerStyle = styled.div`
position: relative;
`
const SvgWrapper = styled.svg`
transform: scale(0.85);
`
const Timer: React.FC = () => {
const size = 100;
const center = size / 2;
const strokeWidth = 10;
const radius = center - strokeWidth / 2;
const [angle, setAngle] = useState(360); // 기본각도
const [draw, setDraw] = useState(``); // path d 값

const radians = (degrees: number) => {
return degrees / 180 * Math.PI;
};

useEffect(() => {
const drawPath = (angle: number) => {
const getArc = (angle: number) => {
const x = center + radius * Math.cos(radians(angle));
const y = center + radius * Math.sin(radians(angle));
return `A${radius},${radius} 1 0 1 ${x},${y}`; // 곡선
};
const firstAngle = angle > 180 ? 90 : angle - 90; // 왼쪽 각도
const secondAngle = -270 + angle - 180; // 오른쪽 각도
const firstArc = getArc(firstAngle); // 왼쪽 반원
const secondArc = angle > 180 ? getArc(secondAngle) : ''; // 오른쪽 반원
const start = `M${center},${center} m0,-${center - strokeWidth / 2}`; // 시작점
const d = `${start} ${firstArc} ${secondArc}`; // 시작점 왼쪽 반원 오른쪽 반원 그리는 값
setDraw(d);
setAngle(angle);
}
drawPath(angle);
}, [angle, center, radius]);

return <TimerStyle>
<SvgWrapper viewBox={`0 0 ${size} ${size}`}>
<defs>
<linearGradient id="gradient">
<stop offset="0%" stopColor="rgb(245,221,100)"/>
<stop offset="100%" stopColor="rgb(234,111,37)"/>
</linearGradient>
</defs>
<circle cx={center} cy={center} r={center} fill='rgb(20,13,4)' stroke='none'/>
<circle cx={center} cy={center} r={center - strokeWidth} fill='#000' stroke='none'/>
<path
strokeLinecap="round"
strokeWidth={strokeWidth}
stroke="url(#gradient)"
fill='none'
d={draw}
/>
<text x={center - 28} y={center + 2} fill="#fff" fontSize="20" fontWeight="900"
style={{userSelect: 'none'}}>02:35
</text>
<text x={center - 20} y={center + 15} fill="rgb(108,108,108)" fontSize="8"
style={{userSelect: 'none'}}>Watch Timer
</text>
</SvgWrapper>
</TimerStyle>
}
export default Timer;

Path 그리기

Timer 시간 기능

App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
import moment from 'moment';
//...
const App: React.FC = () => {
const defaultTime = 180000; // 1000 * 60 * 3 = 3분
const [timer, setTimer] = useState(moment.duration(defaultTime));
const [timerStatus, setTimerStatus] = useState<'play' | 'pause' | 'stop'>('stop');
const [currentTime, setCurrentTime] = useState(moment());
//...
<CurrentTime>{currentTime.format('mm:ss')}</CurrentTime>
//...
<Timer timer={timer} defaultTime={defaultTime}/>
//...

타이머 지정한 시간 및 타이머와 타이머의 현재 상태를 저장해줍니다.
그리고 타이머 컴포넌트에 timer와 defaultTimer를 props로 넘겨주도록 합니다.

App.tsx
1
2
3
4
5
6
7
8
9
10
//...
useEffect(() => {
const currentTimer:NodeJS.Timeout = setInterval(() => {
setCurrentTime(prevCurrentTime => prevCurrentTime.clone().add(1, 'minute'))
}, 60000);
return () => {
clearInterval(currentTimer);
}
}, []);
//...

1분단위로 현재 시간을 변경해주는 이펙트입니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
//...
import moment, { Duration } from 'moment';
//...
const Timer: React.FC<{ timer: Duration, defaultTime: number }> = ({timer, defaultTime}) => {
//...
<text x={center - 28} y={center + 2} fill="#fff" fontSize="20" fontWeight="900" style={{userSelect: 'none'}}>
{moment.utc(timer.asMilliseconds()).format('mm:ss')}
</text>
//...

넘어온 타이머를 텍스트가 변경되도록 수정합니다.

App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//...
import { clearInterval, setInterval } from 'timers';
//...
useEffect(() => {
let timerInterval: NodeJS.Timeout | null = null;
if (timerStatus === 'play') {
timerInterval = setInterval(() => {
setTimer(prevTimer => {
const duration = prevTimer.clone().subtract(1, 'second');
if (prevTimer.asMilliseconds() === 0) {
setTimerStatus('stop');
return moment.duration(0);
}
return duration;
}
)
}, 1000);
} else if (timerStatus === 'pause' || timerStatus === 'stop') {
if (timerInterval) {
clearInterval(timerInterval);
}
}
return () => {
if (timerInterval) {
clearInterval(timerInterval);
}
}
}, [timerStatus]);
//...

타이머의 상태가 play가 되면 1초씩 감소하는 기능입니다.
setInterval 부분의 타입이 에러가 나올시에 setInterval 포스트를 참고 해주세요

App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
//...
const timerControl = () => {
if (timerStatus === 'play') {
setTimerStatus('pause');
} else {
setTimerStatus('play');
}
}
//...
<Button type="button" onClick={() => timerControl()}>
<FontAwesomeIcon icon={timerStatus === 'play' ? faPause : faPlay} color="#fff" size="lg"/>
</Button>
//...

타이머 시작과 중지버튼 컨트롤러 기능을 설정해줍니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
//...
React.useEffect(() => {
if (timer.asMilliseconds() >= 0) {
const percent = timer.asMilliseconds() / defaultTime;
const angle = 360 * percent;
setAngle(angle);
}
}, [timer, defaultTime])
//...

타이머가 변경시 angle을 변경해주는 이펙트입니다.

Timer animation

이것으로 타이머를 만들어 보았습니다. 하지만 뭔가 어색한 점이 있습니다. 그것은 애니메이션이죠!
svg에 애니메이션 기능을 추가하겠습니다.

Timer.tsx
1
2
3
4
5
//...
const Timer: React.FC<{ timer: Duration, defaultTime: number }> = ({timer, defaultTime}) => {
const animDuration = 300; // 애니메이션 속도
const [prevAngle, setPrevAngle] = useState(angle); // 애니메이션 시작 이전 각도
//...

애니메이션이 시작되기 이전의 angle을 별도로 저장해줍니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
//...
React.useEffect(() => {
if (timer.asMilliseconds() >= 0) {
const percent = timer.asMilliseconds() / defaultTime;
const angle = 360 * percent;
setPrevAngle(angle); // setAngle에서 prevAngle로 수정
}
}, [timer, defaultTime])
//...

앞에서 사용했던 이펙트를 조금 수정합니다.
애니메이션 시작전이기 때문에 이전 각도 값으로 먼저 상태를 저장 해줍니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//...
useEffect(() => {
const drawPath = (angle: number) => {
//...
const step = () => {
console.log('애니메이션 단계')
}
const anim = (argAngle: number, time: number) => {
if (argAngle > 360) { // 각도가 360도를 넘어가면 360도의 나머지 값으로 계산합니다
argAngle = argAngle % 360;
}
const startTime = new Date().valueOf(); // 시작시간
const endTime = startTime + time; // 끝나는 시간
const angleOffset = argAngle - angle; // 이전각도와 현재각도 비교
requestAnimationFrame(() => step());
}
anim(prevAngle, animDuration);
}, [prevAngle, animDuration]);
//...

애니메이션 시작과 끝나는 시간 각도 비교값을 step 함수에 넘겨줍니다.
draw 기능을 이펙트 내부로 옮깁니다.

Timer.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//...
useEffect(() => {
//...
const step = (angleOffset: number, argAngle: number, time: number, endTime: number) => {
const now = new Date().valueOf(); // 애니메이션 시작 시간
const timeOffset = endTime - now; // 끝나는 시간 - 시작 시간
if (timeOffset <= 0) { // 끝나는 시간이 지났을때
drawPath(argAngle);
} else {
const incrementAngle = argAngle - (angleOffset * timeOffset / time); // 이전각도 - (이전각도 - 현재각도) * (끝나는 시간 - 시작 시간) / 애니메이션 시간
drawPath(incrementAngle); // path를 그려줍니다.
requestAnimationFrame(() => step(angleOffset, argAngle, time, endTime)); // 끝나는 시간이 지날때까지 step을 계속 실행시킵니다.
}
}
const anim = (argAngle: number, time: number) => {
//...
requestAnimationFrame(() => step(angleOffset, argAngle, time, endTime));
}
//...
}},[prevAngle, animDuration])
//...
Timer.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
useEffect(() => {
const drawPath = (angle: number, finished: boolean) => {
const getArc = (angle: number) => {
const x = center + radius * Math.cos(radians(angle));
const y = center + radius * Math.sin(radians(angle));
return `A${radius},${radius} 1 0 1 ${x},${y}`; // 곡선
};
const firstAngle = angle > 180 ? 90 : angle - 90; // 왼쪽 각도
const secondAngle = -270 + angle - 180; // 오른쪽 각도
const firstArc = getArc(firstAngle); // 왼쪽 반원
const secondArc = angle > 180 ? getArc(secondAngle) : ''; // 오른쪽 반원
const start = `M${center},${center} m0,-${center - strokeWidth / 2}`; // 시작점
const d = `${start} ${firstArc} ${secondArc}`; // 시작점 왼쪽 반원 오른쪽 반원 그리는 값
setDraw(d);
if(finished){
setAnlge(angle);
}
}

const step = (angleOffset: number, argAngle: number, time: number, endTime: number) => {
const now = new Date().valueOf(); // 애니메이션 시작 시간
const timeOffset = endTime - now; // 끝나는 시간 - 시작 시간
if (timeOffset <= 0) { // 끝나는 시간이 지났을때
drawPath(argAngle);
} else {
const incrementAngle = argAngle - (angleOffset * timeOffset / time); // 이전각도 - (이전각도 - 현재각도) * (끝나는 시간 - 시작 시간) / 애니메이션 시간
drawPath(incrementAngle); // path를 그려줍니다.
requestAnimationFrame(() => step(angleOffset, argAngle, time, endTime)); // 끝나는 시간이 지날때까지 step을 계속 실행시킵니다.
}
}

const anim = (argAngle: number, time: number) => {
if (argAngle > 360) { // 각도가 360도를 넘어가면 360도의 나머지 값으로 계산합니다
argAngle = argAngle % 360;
}
const startTime = new Date().valueOf(); // 시작시간
const endTime = startTime + time; // 끝나는 시간
const angleOffset = argAngle - angle; // 이전각도와 현재각도 비교
requestAnimationFrame(() => step(angleOffset, argAngle, time, endTime));
}
anim(prevAngle, animDuration);
}, [prevAngle, animDuration, center, radius, angle]);

useEffect dependancy에서 warning이 나옵니다. dependancy를 추가해줍니다.
angle는 마지막에만 수정할수 있도록 draw 할때 finished 확인을 해줍니다.
이것으로 타이머 애니메이션 기능까지 구현하였습니다.

최종

코드는 깃헙에서 확인할 수 있습니다.
Github: Timer-clone

Share