requestAnimationFrame 활용하기

animation을 사용할 때에는 Timer 함수보다는 requestAnimationFrame을 사용해야 한다.

js의 메인쓰레드가 아닌 애니메이션 프레임에서 실행되기 때문에 애니메이션 프레임이 소실되거나 여러개의 애니메이션을 실행할 경우에 서로 충돌하는 경우를 방지하기 위해서이다.

최근 숫자가 증가하는 애니메이션을 구현하였는데, 요구사항은 다음과 같다.

  1. 증가량이 점점 감소할 것(목표치에 가까워지면 조금씩 증가)
  2. 2초 동안 동작할 것

1번 요구사항때문에 increment라는 변수를 두어 step 함수의 실행마다 step*=0.9를 해주고 setCurNumber(maxNumber-increment)를 통해 구현하였다.

그리고 2번 요구사항으로 인해 Timer 함수가 아닌 requestAnimationFrame을 사용하게 되었는데, 이렇게 구현하게 되면 2번 요구사항을 만족할 수 없었다.

requestAnimationFrame의 호출주기는 주사율에 따라 결정되고 주사율은 다 다르기 떄문에 기기마다 같은 시간동안 호출되는 횟수도 다르고 시간제한을 넣게되면 호출 횟수, 시간, 현재 값의 세 변수에 따라 실행하게 되는데 이러한 점화식을 찾지 못하였다.

결론적으로 말하자면 위에서 말한 점화식(ease함수)를 만들어 가중치를 계산하여 증가속도를 구현하였다.

ease함수는 여기를 참조하였다.

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
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber = (
maxNumber,
initialNumber = 0,
duration = 2000
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);

const step = useCallback((timeStamp) => {
if (curTime.current === null) curTime.current = timeStamp;

const progress = timeStamp - curTime.current;

const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(progress / duration)
);

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;

setcurCount(nextCurCount);

if (progress < duration) {
window.requestAnimationFrame(step);
}
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};

주사율을 제한하여 구현하는 아이디어도 생각했는데, 이에 대해서 정리해보려고 한다.

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
import { useState, useEffect, useRef, useCallback } from "react";

function easeOutExpo(t) {
return t == 1 ? 1 : -Math.pow(2, -10 * t) + 1;
}

export const useIncreaseNumber2 = (
maxNumber,
initialNumber = 0,
duration = 1000,
fpsHz = 1000 / 30
) => {
const [curCount, setcurCount] = useState(initialNumber);
const curTime = useRef(null);
const lastTime = useRef(null);
const cnt = useRef(0);

const step = useCallback((timeStamp) => {
if (curTime.current === null && lastTime.current === null) {
curTime.current = window.performance.now();
lastTime.current = window.performance.now();
}

const totalElapsed = window.performance.now() - curTime.current;
// 총 실행시간으로 duration과 비교하여 rAF를 탈출하기위해 사용한다.

if (totalElapsed > duration) {
return;
}
// duration일경우 탈출

const elapsed = timeStamp - lastTime.current;
// 마지막 상태변경과의 시간 차이

if (elapsed >= fpsHz) {
// 마지막 상태변경과의 시간차이가 주사율 이상일 경우에만 값을 반영한다.
const nextCount = Math.ceil(
(maxNumber - initialNumber) * easeOutExpo(totalElapsed / duration)
);
// ease 함수를 이용하여 가중치를 계산하여 반영한다.

const nextCurCount = nextCount > maxNumber ? maxNumber : nextCount;
// 호출 횟수에 따른 보정을 해준다.

console.log(++cnt.current);
// 몇 번 렌더링 되었는지 출력한다.
setcurCount(nextCurCount);
// state를 바꾼다
lastTime.current = timeStamp - (elapsed % fpsHz);
// 마지막 상태변경시간을 업데이트한다.
}
window.requestAnimationFrame(step);
}, []);

useEffect(() => {
window.requestAnimationFrame(step);
}, []);

return curCount;
};
// cnt.current의 마지막 값은 29가 출력된다.

setInterval의 경우 두 번째 parameter가 호출 주기이기 때문에 반환값을 통해서 쓰로틀링을 충분히 구현할 수 있지만, requestAnimationFrame의 경우 window.perfomance.now()를 통해 이를 직접 구현해줘야한다.

상태를 변경하여 브라우저에 변경사항이 반영되는 횟수를 제한하였다.

신경써야 할 점은 lastTime.current 계산식이다.

timeStamp는 마지막 호출의 시간이고 elapsed는 timeStamp와 마지막 상태 변경시간의 차이이다.

상태를 변경하는 조건은 elapsed가 fpsHz보다 클 경우이므로 lastTime을 timeStamp로 하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다.

lastTime.current에 timeStamp를 그냥 저장하게 되면 elapsed-fpsHz만큼의 값이 축적되어 계산되게 된다. 떄문에 lastTime.current에는 timeStamp-(elapsed-fpsHz)를 저장해주어야 한다.

elapsed값은 계속 증가하는 값이기 때문에 나누기 연산으로 계산해주었다.

댓글