Skip to content

Commit a758f8f

Browse files
committed
feat: useInterpolations
1 parent 9ef9535 commit a758f8f

6 files changed

Lines changed: 296 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
- [**Animations**](./docs/Animations.md)
8989
- [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`.
9090
- [`useInterval`](./docs/useInterval.md) and [`useHarmonicIntervalFn`](./docs/useHarmonicIntervalFn.md) — re-renders component on a set interval using `setInterval`.
91+
- [`useInterpolations`](./docs/useInterpolations.md) — interpolates a map of numeric values over time.
9192
- [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics.
9293
- [`useTimeout`](./docs/useTimeout.md) — re-renders component after a timeout.
9394
- [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo)

docs/useInterpolations.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# `useInterpolations`
2+
3+
React animation hook that interpolates a map of numeric values over time.
4+
5+
## Usage
6+
7+
```jsx
8+
import { useInterpolations } from 'react-use';
9+
10+
const Demo = () => {
11+
const values = useInterpolations({
12+
left: [0, 100],
13+
top: [0, 50],
14+
opacity: [0, 1]
15+
}, 'inCirc', 1000);
16+
17+
return (
18+
<div
19+
style={{
20+
position: 'relative',
21+
left: values.left,
22+
top: values.top,
23+
opacity: values.opacity,
24+
width: 100,
25+
height: 100,
26+
background: 'tomato'
27+
}}
28+
/>
29+
);
30+
};
31+
```
32+
33+
## Reference
34+
35+
```ts
36+
useInterpolations<T extends Record<string, readonly [number, number]>>(
37+
map: T,
38+
easingName?: string,
39+
ms?: number,
40+
delay?: number
41+
): { [K in keyof T]: number }
42+
```
43+
44+
Returns an object with the same keys as `map`, where each value is interpolated between its `[start, end]` range.
45+
46+
- `map` &mdash; required, object where each value is a `[start, end]` tuple of numbers to interpolate.
47+
- `easingName` &mdash; one of the valid [easing names](https://github.com/streamich/ts-easing/blob/master/src/index.ts), defaults to `inCirc`.
48+
- `ms` &mdash; milliseconds for how long to keep re-rendering component, defaults to `200`.
49+
- `delay` &mdash; delay in milliseconds after which to start re-rendering component, defaults to `0`.
50+

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export { default as useTimeout } from './useTimeout';
9595
export { default as useTimeoutFn } from './useTimeoutFn';
9696
export { default as useTitle } from './useTitle';
9797
export { default as useToggle } from './useToggle';
98+
export { default as useInterpolations } from './useInterpolations';
9899
export { default as useTween } from './useTween';
99100
export { default as useUnmount } from './useUnmount';
100101
export { default as useUnmountPromise } from './useUnmountPromise';

src/useInterpolations.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useMemo } from "react";
2+
import useTween from "./useTween";
3+
4+
export type InterpolationMap = Record<string, readonly [number, number]>;
5+
6+
const formatMapEntryValue = (value: unknown): string => {
7+
try {
8+
const json = JSON.stringify(value);
9+
if (typeof json === "string") {
10+
return json;
11+
}
12+
} catch {
13+
// ignore
14+
}
15+
16+
return String(value);
17+
};
18+
19+
const useInterpolations = <T extends InterpolationMap>(
20+
map: T,
21+
easingName: string = "inCirc",
22+
ms: number = 200,
23+
delay: number = 0
24+
): { [K in keyof T]: number } => {
25+
const t = useTween(easingName, ms, delay);
26+
27+
return useMemo(() => {
28+
if (process.env.NODE_ENV !== "production") {
29+
if (!map || typeof map !== "object") {
30+
console.error('useInterpolations() expected "map" to be an object.');
31+
return {} as { [K in keyof T]: number };
32+
}
33+
34+
const keys = Object.keys(map) as Array<keyof T>;
35+
36+
for (const key of keys) {
37+
const value = map[key];
38+
const keyString = String(key);
39+
if (!Array.isArray(value) || value.length !== 2) {
40+
const valueString = formatMapEntryValue(value);
41+
console.error(
42+
`useInterpolations() expected map["${keyString}"] to be a [start, end] tuple, got ${valueString}.`
43+
);
44+
return {} as { [K in keyof T]: number };
45+
}
46+
if (typeof value[0] !== "number" || typeof value[1] !== "number") {
47+
console.error(
48+
`useInterpolations() expected map["${keyString}"] to contain numbers, got [${typeof value[0]}, ${typeof value[1]}].`
49+
);
50+
return {} as { [K in keyof T]: number };
51+
}
52+
if (!Number.isFinite(value[0]) || !Number.isFinite(value[1])) {
53+
console.error(
54+
`useInterpolations() expected map["${keyString}"] to contain finite numbers, got [${value[0]}, ${value[1]}].`
55+
);
56+
return {} as { [K in keyof T]: number };
57+
}
58+
}
59+
}
60+
61+
const result = {} as { [K in keyof T]: number };
62+
const keys = Object.keys(map) as Array<keyof T>;
63+
for (const key of keys) {
64+
const [start, end] = map[key];
65+
result[key] = start + (end - start) * t;
66+
}
67+
return result;
68+
}, [map, t]);
69+
};
70+
71+
export default useInterpolations;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { storiesOf } from '@storybook/react';
2+
import * as React from 'react';
3+
import { useInterpolations } from '../src';
4+
import ShowDocs from './util/ShowDocs';
5+
6+
const Demo = () => {
7+
const values = useInterpolations(
8+
{
9+
left: [0, 300],
10+
top: [0, 200],
11+
opacity: [0, 1],
12+
},
13+
'inOutCirc',
14+
2000
15+
);
16+
17+
return (
18+
<div>
19+
<div
20+
style={{
21+
position: 'relative',
22+
left: values.left,
23+
top: values.top,
24+
opacity: values.opacity,
25+
width: 100,
26+
height: 100,
27+
background: 'tomato',
28+
}}
29+
/>
30+
<pre>{JSON.stringify(values, null, 2)}</pre>
31+
</div>
32+
);
33+
};
34+
35+
storiesOf('Animation/useInterpolations', module)
36+
.add('Docs', () => <ShowDocs md={require('../docs/useInterpolations.md')} />)
37+
.add('Demo', () => <Demo />);

tests/useInterpolations.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { renderHook } from "@testing-library/react-hooks";
2+
import * as useTween from "../src/useTween";
3+
import useInterpolations from "../src/useInterpolations";
4+
5+
let spyUseTween;
6+
7+
beforeEach(() => {
8+
spyUseTween = jest.spyOn(useTween, "default").mockReturnValue(0.5);
9+
});
10+
11+
afterEach(() => {
12+
jest.restoreAllMocks();
13+
});
14+
15+
it("should interpolate map values with default parameters", () => {
16+
const { result } = renderHook(() =>
17+
useInterpolations({
18+
left: [0, 100],
19+
top: [50, 150],
20+
opacity: [0, 1],
21+
})
22+
);
23+
24+
expect(result.current.left).toBe(50);
25+
expect(result.current.top).toBe(100);
26+
expect(result.current.opacity).toBe(0.5);
27+
expect(spyUseTween).toHaveBeenCalledTimes(1);
28+
expect(spyUseTween).toHaveBeenCalledWith("inCirc", 200, 0);
29+
});
30+
31+
it("should interpolate map values with custom parameters", () => {
32+
const { result } = renderHook(() =>
33+
useInterpolations(
34+
{
35+
x: [10, 20],
36+
y: [-5, 5],
37+
},
38+
"outCirc",
39+
500,
40+
100
41+
)
42+
);
43+
44+
expect(result.current.x).toBe(15);
45+
expect(result.current.y).toBe(0);
46+
expect(spyUseTween).toHaveBeenCalledTimes(1);
47+
expect(spyUseTween).toHaveBeenCalledWith("outCirc", 500, 100);
48+
});
49+
50+
it("should interpolate at t=0", () => {
51+
spyUseTween.mockReturnValue(0);
52+
53+
const { result } = renderHook(() =>
54+
useInterpolations({
55+
left: [10, 90],
56+
top: [20, 80],
57+
})
58+
);
59+
60+
expect(result.current.left).toBe(10);
61+
expect(result.current.top).toBe(20);
62+
});
63+
64+
it("should interpolate at t=1", () => {
65+
spyUseTween.mockReturnValue(1);
66+
67+
const { result } = renderHook(() =>
68+
useInterpolations({
69+
left: [10, 90],
70+
top: [20, 80],
71+
})
72+
);
73+
74+
expect(result.current.left).toBe(90);
75+
expect(result.current.top).toBe(80);
76+
});
77+
78+
describe("when invalid map is provided", () => {
79+
beforeEach(() => {
80+
jest.spyOn(console, "error").mockImplementation(() => {});
81+
});
82+
83+
it("should log an error when map is not an object", () => {
84+
const { result } = renderHook(() =>
85+
useInterpolations(null as unknown as Record<string, readonly [number, number]>)
86+
);
87+
88+
expect(result.current).toEqual({});
89+
expect(console.error).toHaveBeenCalledTimes(1);
90+
expect(console.error).toHaveBeenCalledWith(
91+
'useInterpolations() expected "map" to be an object.'
92+
);
93+
});
94+
95+
it("should log an error when map value is not a tuple", () => {
96+
const { result } = renderHook(() =>
97+
useInterpolations({
98+
left: [10] as unknown as readonly [number, number],
99+
})
100+
);
101+
102+
expect(result.current).toEqual({});
103+
expect(console.error).toHaveBeenCalledTimes(1);
104+
expect(console.error).toHaveBeenCalledWith(
105+
expect.stringContaining('useInterpolations() expected map["left"] to be a [start, end] tuple')
106+
);
107+
});
108+
109+
it("should log an error when map value contains non-numbers", () => {
110+
const { result } = renderHook(() =>
111+
useInterpolations({
112+
left: ["0", 100] as unknown as readonly [number, number],
113+
})
114+
);
115+
116+
expect(result.current).toEqual({});
117+
expect(console.error).toHaveBeenCalledTimes(1);
118+
expect(console.error).toHaveBeenCalledWith(
119+
expect.stringContaining('useInterpolations() expected map["left"] to contain numbers')
120+
);
121+
});
122+
123+
it("should log an error when map value contains non-finite numbers", () => {
124+
const { result } = renderHook(() =>
125+
useInterpolations({
126+
left: [0, Infinity],
127+
})
128+
);
129+
130+
expect(result.current).toEqual({});
131+
expect(console.error).toHaveBeenCalledTimes(1);
132+
expect(console.error).toHaveBeenCalledWith(
133+
expect.stringContaining('useInterpolations() expected map["left"] to contain finite numbers')
134+
);
135+
});
136+
});

0 commit comments

Comments
 (0)