diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx new file mode 100644 index 0000000..5eaa337 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.mdx @@ -0,0 +1,61 @@ +import { Meta } from '@storybook/blocks'; + + + +# useAnimationLoop + +`useAnimationLoop` hook is a wrapper around requestAnimationFrame function. This hook will execute +the passed callback function every frame. + +## Reference + +```ts +function useAnimationLoop(callback: (delta: number) => void, enabled = false): void; +``` + +### Parameters + +- `callback` - function accepts `delta` parameter which represents time passed since last + invocation; +- `enabled` - boolean which is used to play and pause the requestAnimationFrame + +### Returns + +- `void` + +## Usage + +```tsx +function DemoComponent(): ReactElement { + const [currentTimestamp, setCurrentTimestamp] = useState(Date.now); + const [isRunning, toggleIsRunning] = useToggle(true); + useAnimationLoop(() => { + setCurrentTimestamp(Date.now); + }, isRunning); + + return ( +
+
+

Instructions!

+

Click on the button to toggle the animation loop

+
+
+
Test Area
+
+

{currentTimestamp}

+ + +
+
+
+ ); +} +``` diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx new file mode 100644 index 0000000..2c6fdd8 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.stories.tsx @@ -0,0 +1,52 @@ +/* eslint-disable react/jsx-no-literals */ +import type { StoryObj } from '@storybook/react'; +import { useState, type ReactElement } from 'react'; +import { useToggle } from '../useToggle/useToggle.js'; +import { useAnimationLoop } from './useAnimationLoop.js'; + +export default { + title: 'hooks/useAnimationLoop', +}; + +function DemoComponent(): ReactElement { + const [timestamp, setTimestamp] = useState(0); + const [delta, setDelta] = useState(0); + const [isRunning, toggleIsRunning] = useToggle(true); + + useAnimationLoop((_timestamp: DOMHighResTimeStamp) => { + setDelta(_timestamp - timestamp); + setTimestamp(_timestamp); + }, isRunning); + + return ( +
+
+

Instructions!

+

Click on the button to toggle the animation loop

+
+
+
Test Area
+
+

Timestamp: {timestamp}

+

Delta: {delta}

+ + +
+
+
+ ); +} + +export const Demo: StoryObj = { + render() { + return ; + }, +}; diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx new file mode 100644 index 0000000..a2c1304 --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.test.tsx @@ -0,0 +1,106 @@ +import { jest } from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useAnimationLoop } from './useAnimationLoop.js'; + +describe('useAnimationLoop', () => { + it('should not crash', async () => { + renderHook(useAnimationLoop, { + initialProps: undefined, + }); + }); + + it('should not execute the callback function when enabled is set to false', async () => { + const spy = jest.fn(); + + renderHook( + ({ callback }) => { + useAnimationLoop(callback, false); + }, + { + initialProps: { + callback: spy, + }, + }, + ); + + await waitFor(() => { + expect(spy).toBeCalledTimes(0); + }); + }); + + it('should execute the callback function when useAnimationLoop is enabled', async () => { + const spy = jest.fn(); + + renderHook( + ({ callback }) => { + useAnimationLoop(callback); + }, + { + initialProps: { + callback: spy, + }, + }, + ); + + await waitFor(() => { + expect(spy).toBeCalled(); + }); + }); + + it('should not execute previous callback function when callback function is updated', async () => { + const spyFirstRender = jest.fn(); + const spySecondRender = jest.fn(); + + const { rerender } = renderHook( + ({ callback }) => { + useAnimationLoop(callback); + }, + { + initialProps: { + callback: spyFirstRender, + }, + }, + ); + + await waitFor(() => { + expect(spyFirstRender).toBeCalled(); + expect(spySecondRender).toBeCalledTimes(0); + }); + + rerender({ callback: spySecondRender }); + const amountOfCalls = spyFirstRender.mock.calls.length; + + await waitFor(() => { + expect(spyFirstRender).toBeCalledTimes(amountOfCalls); + expect(spySecondRender).toBeCalled(); + }); + }); + + it('should execute the callback function when useAnimationLoop is enabled and should not execute the callback function when useAnimationLoop is disabled', async () => { + const spy = jest.fn(); + + const { rerender } = renderHook( + ({ callback, enabled }) => { + useAnimationLoop(callback, enabled); + }, + { + initialProps: { + callback: spy, + enabled: true, + }, + }, + ); + + await waitFor(() => { + expect(spy).toBeCalled(); + }); + + rerender({ callback: spy, enabled: false }); + + const amountOfCalls = spy.mock.calls.length; + + await waitFor(() => { + expect(spy).toBeCalledTimes(amountOfCalls); + }); + }); +}); diff --git a/src/hooks/useAnimationLoop/useAnimationLoop.ts b/src/hooks/useAnimationLoop/useAnimationLoop.ts new file mode 100644 index 0000000..6f78f5d --- /dev/null +++ b/src/hooks/useAnimationLoop/useAnimationLoop.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRefValue } from '../useRefValue/useRefValue.js'; + +/** + * useAnimationLoop hook is a wrapper around requestAnimationFrame method. + * This hook will execute a callback function every next frame. + * + * @param callback - callback function with @param delta which represents time passed since last invocation + * @param enabled - boolean which is used to play and pause the requestAnimationFrame + */ +export function useAnimationLoop(callback: FrameRequestCallback, enabled = true): void { + const animationFrameRef = useRef(0); + const callbackRef = useRefValue(callback); + + const tick = useCallback( + (time) => { + callbackRef.current?.(time); + animationFrameRef.current = requestAnimationFrame(tick); + }, + [callbackRef], + ); + + useEffect(() => { + if (enabled) { + requestAnimationFrame(tick); + } else { + cancelAnimationFrame(animationFrameRef.current); + } + + return () => { + cancelAnimationFrame(animationFrameRef.current); + }; + }, [enabled, tick]); +}