-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feature: add useAnimationLoop hook #129
base: main
Are you sure you want to change the base?
Changes from all commits
e79725b
78fb423
f292c54
50cb296
143d551
50a8085
926e091
de98f6a
af949e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,61 @@ | ||||||||||
import { Meta } from '@storybook/blocks'; | ||||||||||
|
||||||||||
<Meta title="hooks/useAnimationLoop" /> | ||||||||||
|
||||||||||
# 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; | ||||||||||
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
- `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); | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In this example, it might be more useful to do something (e.g. log) the |
||||||||||
}, isRunning); | ||||||||||
|
||||||||||
return ( | ||||||||||
<div> | ||||||||||
<div className="alert alert-primary"> | ||||||||||
<h4 className="alert-heading">Instructions!</h4> | ||||||||||
<p className="mb-0">Click on the button to toggle the animation loop</p> | ||||||||||
</div> | ||||||||||
<div className="card border-dark" data-ref="test-area"> | ||||||||||
<div className="card-header">Test Area</div> | ||||||||||
<div className="card-body"> | ||||||||||
<p>{currentTimestamp}</p> | ||||||||||
|
||||||||||
<button | ||||||||||
type="button" | ||||||||||
// eslint-disable-next-line react/jsx-handler-names, react/jsx-no-bind | ||||||||||
onClick={(): void => { | ||||||||||
toggleIsRunning(); | ||||||||||
}} | ||||||||||
> | ||||||||||
Toggle animation loop | ||||||||||
</button> | ||||||||||
</div> | ||||||||||
</div> | ||||||||||
</div> | ||||||||||
); | ||||||||||
} | ||||||||||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div> | ||
<div className="alert alert-primary"> | ||
<h4 className="alert-heading">Instructions!</h4> | ||
<p className="mb-0">Click on the button to toggle the animation loop</p> | ||
</div> | ||
<div className="card border-dark" data-ref="test-area"> | ||
<div className="card-header">Test Area</div> | ||
<div className="card-body"> | ||
<p>Timestamp: {timestamp}</p> | ||
<p>Delta: {delta}</p> | ||
|
||
<button | ||
type="button" | ||
// eslint-disable-next-line react/jsx-handler-names, react/jsx-no-bind | ||
onClick={(): void => { | ||
toggleIsRunning(); | ||
}} | ||
> | ||
Toggle animation loop | ||
</button> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
export const Demo: StoryObj = { | ||
render() { | ||
return <DemoComponent />; | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { jest } from '@jest/globals'; | ||
import { renderHook, waitFor } from '@testing-library/react'; | ||
import { useAnimationLoop } from './useAnimationLoop.js'; | ||
|
||
describe('useAnimationLoop', () => { | ||
leroykorterink marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This docs (still) say When running an individual animationFrame, the time might be useful. When you running them in a loop, the delta is often more useful. |
||
* @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<FrameRequestCallback>( | ||
(time) => { | ||
callbackRef.current?.(time); | ||
animationFrameRef.current = requestAnimationFrame(tick); | ||
}, | ||
[callbackRef], | ||
); | ||
|
||
useEffect(() => { | ||
if (enabled) { | ||
requestAnimationFrame(tick); | ||
} else { | ||
cancelAnimationFrame(animationFrameRef.current); | ||
} | ||
|
||
return () => { | ||
cancelAnimationFrame(animationFrameRef.current); | ||
}; | ||
}, [enabled, tick]); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.