Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
- [**Animations**](./docs/Animations.md)
- [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`.
- [`useInterval`](./docs/useInterval.md) and [`useHarmonicIntervalFn`](./docs/useHarmonicIntervalFn.md) — re-renders component on a set interval using `setInterval`.
- [`useInterpolations`](./docs/useInterpolations.md) — interpolates a map of numeric values over time.
- [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics.
- [`useTimeout`](./docs/useTimeout.md) — re-renders component after a timeout.
- [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo)
Expand Down
50 changes: 50 additions & 0 deletions docs/useInterpolations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# `useInterpolations`

React animation hook that interpolates a map of numeric values over time.

## Usage

```jsx
import { useInterpolations } from 'react-use';

const Demo = () => {
const values = useInterpolations({
left: [0, 100],
top: [0, 50],
opacity: [0, 1]
}, 'inCirc', 1000);

return (
<div
style={{
position: 'relative',
left: values.left,
top: values.top,
opacity: values.opacity,
width: 100,
height: 100,
background: 'tomato'
}}
/>
);
};
```

## Reference

```ts
useInterpolations<T extends Record<string, readonly [number, number]>>(
map: T,
easingName?: string,
ms?: number,
delay?: number
): { [K in keyof T]: number }
```

Returns an object with the same keys as `map`, where each value is interpolated between its `[start, end]` range.

- `map` &mdash; required, object where each value is a `[start, end]` tuple of numbers to interpolate.
- `easingName` &mdash; one of the valid [easing names](https://github.com/streamich/ts-easing/blob/master/src/index.ts), defaults to `inCirc`.
- `ms` &mdash; milliseconds for how long to keep re-rendering component, defaults to `200`.
- `delay` &mdash; delay in milliseconds after which to start re-rendering component, defaults to `0`.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export { default as useTimeout } from './useTimeout';
export { default as useTimeoutFn } from './useTimeoutFn';
export { default as useTitle } from './useTitle';
export { default as useToggle } from './useToggle';
export { default as useInterpolations } from './useInterpolations';
export { default as useTween } from './useTween';
export { default as useUnmount } from './useUnmount';
export { default as useUnmountPromise } from './useUnmountPromise';
Expand Down
71 changes: 71 additions & 0 deletions src/useInterpolations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useMemo } from "react";
import useTween from "./useTween";

export type InterpolationMap = Record<string, readonly [number, number]>;

const formatMapEntryValue = (value: unknown): string => {
try {
const json = JSON.stringify(value);
if (typeof json === "string") {
return json;
}
} catch {
// ignore
}

return String(value);
};

const useInterpolations = <T extends InterpolationMap>(
map: T,
easingName: string = "inCirc",
ms: number = 200,
delay: number = 0
): { [K in keyof T]: number } => {
const t = useTween(easingName, ms, delay);

return useMemo(() => {
if (process.env.NODE_ENV !== "production") {
if (!map || typeof map !== "object") {
console.error('useInterpolations() expected "map" to be an object.');
return {} as { [K in keyof T]: number };
}

const keys = Object.keys(map) as Array<keyof T>;

for (const key of keys) {
const value = map[key];
const keyString = String(key);
if (!Array.isArray(value) || value.length !== 2) {
const valueString = formatMapEntryValue(value);
console.error(
`useInterpolations() expected map["${keyString}"] to be a [start, end] tuple, got ${valueString}.`
);
return {} as { [K in keyof T]: number };
}
if (typeof value[0] !== "number" || typeof value[1] !== "number") {
console.error(
`useInterpolations() expected map["${keyString}"] to contain numbers, got [${typeof value[0]}, ${typeof value[1]}].`
);
return {} as { [K in keyof T]: number };
}
if (!Number.isFinite(value[0]) || !Number.isFinite(value[1])) {
console.error(
`useInterpolations() expected map["${keyString}"] to contain finite numbers, got [${value[0]}, ${value[1]}].`
);
return {} as { [K in keyof T]: number };
}
}
}

const result = {} as { [K in keyof T]: number };
const keys = Object.keys(map) as Array<keyof T>;
for (const key of keys) {
const [start, end] = map[key];
result[key] = start + (end - start) * t;
}
return result;
}, [map, t]);
};

export default useInterpolations;
37 changes: 37 additions & 0 deletions stories/useInterpolations.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useInterpolations } from '../src';
import ShowDocs from './util/ShowDocs';

const Demo = () => {
const values = useInterpolations(
{
left: [0, 300],
top: [0, 200],
opacity: [0, 1],
},
'inOutCirc',
2000
);

return (
<div>
<div
style={{
position: 'relative',
left: values.left,
top: values.top,
opacity: values.opacity,
width: 100,
height: 100,
background: 'tomato',
}}
/>
<pre>{JSON.stringify(values, null, 2)}</pre>
</div>
);
};

storiesOf('Animation/useInterpolations', module)
.add('Docs', () => <ShowDocs md={require('../docs/useInterpolations.md')} />)
.add('Demo', () => <Demo />);
136 changes: 136 additions & 0 deletions tests/useInterpolations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { renderHook } from "@testing-library/react-hooks";
import * as useTween from "../src/useTween";
import useInterpolations from "../src/useInterpolations";

let spyUseTween;

beforeEach(() => {
spyUseTween = jest.spyOn(useTween, "default").mockReturnValue(0.5);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("should interpolate map values with default parameters", () => {
const { result } = renderHook(() =>
useInterpolations({
left: [0, 100],
top: [50, 150],
opacity: [0, 1],
})
);

expect(result.current.left).toBe(50);
expect(result.current.top).toBe(100);
expect(result.current.opacity).toBe(0.5);
expect(spyUseTween).toHaveBeenCalledTimes(1);
expect(spyUseTween).toHaveBeenCalledWith("inCirc", 200, 0);
});

it("should interpolate map values with custom parameters", () => {
const { result } = renderHook(() =>
useInterpolations(
{
x: [10, 20],
y: [-5, 5],
},
"outCirc",
500,
100
)
);

expect(result.current.x).toBe(15);
expect(result.current.y).toBe(0);
expect(spyUseTween).toHaveBeenCalledTimes(1);
expect(spyUseTween).toHaveBeenCalledWith("outCirc", 500, 100);
});

it("should interpolate at t=0", () => {
spyUseTween.mockReturnValue(0);

const { result } = renderHook(() =>
useInterpolations({
left: [10, 90],
top: [20, 80],
})
);

expect(result.current.left).toBe(10);
expect(result.current.top).toBe(20);
});

it("should interpolate at t=1", () => {
spyUseTween.mockReturnValue(1);

const { result } = renderHook(() =>
useInterpolations({
left: [10, 90],
top: [20, 80],
})
);

expect(result.current.left).toBe(90);
expect(result.current.top).toBe(80);
});

describe("when invalid map is provided", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});

it("should log an error when map is not an object", () => {
const { result } = renderHook(() =>
useInterpolations(null as unknown as Record<string, readonly [number, number]>)
);

expect(result.current).toEqual({});
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
'useInterpolations() expected "map" to be an object.'
);
});

it("should log an error when map value is not a tuple", () => {
const { result } = renderHook(() =>
useInterpolations({
left: [10] as unknown as readonly [number, number],
})
);

expect(result.current).toEqual({});
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('useInterpolations() expected map["left"] to be a [start, end] tuple')
);
});

it("should log an error when map value contains non-numbers", () => {
const { result } = renderHook(() =>
useInterpolations({
left: ["0", 100] as unknown as readonly [number, number],
})
);

expect(result.current).toEqual({});
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('useInterpolations() expected map["left"] to contain numbers')
);
});

it("should log an error when map value contains non-finite numbers", () => {
const { result } = renderHook(() =>
useInterpolations({
left: [0, Infinity],
})
);

expect(result.current).toEqual({});
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('useInterpolations() expected map["left"] to contain finite numbers')
);
});
});