Skip to content

Commit

Permalink
Support listening to prefers-reduced-motion setting
Browse files Browse the repository at this point in the history
  • Loading branch information
avivian committed Mar 17, 2023
1 parent 04910f7 commit 4493636
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 14 deletions.
18 changes: 11 additions & 7 deletions examples/stories/RiveOverview.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { useState } from 'react';

import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';

import RiveComponent, {useRive, useStateMachineInput} from '../../src';
import {Button} from './components/Button';
import RiveComponent, { useRive, useStateMachineInput } from '../../src';
import { Button } from './components/Button';
import './rive-overview.css';

<Meta title="React Runtime/Overview" />
Expand All @@ -31,6 +31,7 @@ There's multiple ways to render Rive using the React runtime. See the associated
```tsx
import RiveComponent from '@rive-app/react-canvas';
```

The React runtime exports a default React component you can insert as JSX. Under the hood, it renders a `<canvas>` element that runs the animation, and a wrapping `<div>` element that handles sizing of the canvas based on the parent that wraps the component.

**When to use this**: Use this for simple rendering cases where you don't need to control playback or setup state machine inputs to advance state machines. It will simply autoplay the first animation it finds in the `.riv`, the animation name you provide it, or the state machine name if you provide one.
Expand All @@ -56,20 +57,21 @@ In addition to the props laid out below, the component accepts other props that
### useRive Hook

```tsx
import {useRive} from '@rive-app/react-canvas';
import { useRive } from '@rive-app/react-canvas';
```

The runtime also exports a named `useRive` hook that allows for more control at Rive instantiation, since it passes back a `rive` object you can use to manipulate state machines, control playback, and more.

**When to use this:** When you need to control your Rive animation in any aspect, such as controlling playback, using state machine inputs to advance state machines, add adding callbacks on certain Rive-specific events such as `onStateChange`, `onPause`, etc.

<Canvas withSource="open">
<Story name="useRive Hook">
{() => {
const [isPlaying, setIsPlaying] = useState(true);
const [animationText, setAnimationText] = useState('');
const { rive, RiveComponent: RiveComponentPlayback } = useRive({
src: 'truck.riv',
stateMachines: "drive",
stateMachines: 'drive',
artboard: 'Truck',
autoplay: true,
onPause: () => {
Expand All @@ -88,15 +90,17 @@ The runtime also exports a named `useRive` hook that allows for more control at
setIsPlaying(true);
}
};
return ((
return (
<>
<div className="center">
<RiveComponentPlayback className="base-canvas-size" />
<p>{animationText}</p>
<Button onClick={togglePlaying}>{isPlaying ? 'Pause' : 'Play'}</Button>
<Button onClick={togglePlaying}>
{isPlaying ? 'Pause' : 'Play'}
</Button>
</div>
</>
));
);
}}
</Story>
</Canvas>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"format": "prettier --write src",
"types:check": "tsc --noEmit",
"release": "release-it",
"storybook": "start-storybook -p 6006",
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
"build-storybook": "build-storybook -o docs-build"
},
"repository": {
Expand Down
9 changes: 9 additions & 0 deletions setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ window.IntersectionObserver = class IntersectionObserver {
unobserve() {}
};

window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));

jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
Expand Down
8 changes: 7 additions & 1 deletion src/components/Rive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export interface RiveProps {
* For `@rive-app/react-webgl`, sets this property to maintain a single WebGL context for multiple canvases. **We recommend to keep the default value** when rendering multiple Rive instances on a page.
*/
useOffscreenRenderer?: boolean;
};
/**
* If true, the runtime will respect the users "prefers-reduced-motion" accessibilty option and start the animation paused. Defaults to false.
*/
usePrefersReducedMotion?: boolean;
}

const Rive = ({
src,
Expand All @@ -37,6 +41,7 @@ const Rive = ({
stateMachines,
layout,
useOffscreenRenderer = true,
usePrefersReducedMotion = false,
...rest
}: RiveProps & ComponentProps<'canvas'>) => {
const params = {
Expand All @@ -50,6 +55,7 @@ const Rive = ({

const options = {
useOffscreenRenderer,
usePrefersReducedMotion,
};

const { RiveComponent } = useRive(params, options);
Expand Down
23 changes: 21 additions & 2 deletions src/hooks/useRive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
RiveState,
Dimensions,
} from '../types';
import { useSize, useDevicePixelRatio } from '../utils';
import {
useSize,
useDevicePixelRatio,
usePrefersReducedMotion,
} from '../utils';

type RiveComponentProps = {
setContainerRef: RefCallback<HTMLElement>;
Expand Down Expand Up @@ -52,6 +56,7 @@ const defaultOptions = {
useDevicePixelRatio: true,
fitCanvasToArtboardHeight: false,
useOffscreenRenderer: true,
usePrefersReducedMotion: false,
};

/**
Expand Down Expand Up @@ -100,6 +105,7 @@ export default function useRive(
// occur.
const size = useSize(containerRef);
const currentDevicePixelRatio = useDevicePixelRatio();
const prefersReducedMotion = usePrefersReducedMotion();

const isParamsLoaded = Boolean(riveParams);
const options = getOptions(opts);
Expand Down Expand Up @@ -198,6 +204,20 @@ export default function useRive(
}
}, [rive, size, currentDevicePixelRatio]);

const animations = riveParams?.animations;
/**
* Listen to changes on the for the prefersReducedMotion accessibilty setting
*/
useEffect(() => {
if (rive && options.usePrefersReducedMotion) {
if (prefersReducedMotion && rive.isPlaying) {
rive.pause();
} else if (!prefersReducedMotion && rive.isPaused) {
rive.play();
}
}
}, [rive, prefersReducedMotion]);

/**
* Ref callback called when the canvas element mounts and unmounts.
*/
Expand Down Expand Up @@ -275,7 +295,6 @@ export default function useRive(
/**
* Listen for changes in the animations params
*/
const animations = riveParams?.animations;
useEffect(() => {
if (rive && animations) {
if (rive.isPlaying) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type UseRiveOptions = {
useDevicePixelRatio: boolean;
fitCanvasToArtboardHeight: boolean;
useOffscreenRenderer: boolean;
usePrefersReducedMotion: boolean;
};

export type Dimensions = {
Expand Down
24 changes: 21 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Dimensions } from './types';

// There are polyfills for this, but they add hundreds of lines of code
class FakeResizeObserver {
observe() { }
unobserve() { }
disconnect() { }
observe() {}
unobserve() {}
disconnect() {}
}

function throttle(f: Function, delay: number) {
Expand Down Expand Up @@ -127,3 +127,21 @@ export function getDevicePixelRatio(): number {
const dpr = hasDprProp ? window.devicePixelRatio : 1;
return Math.min(Math.max(1, dpr), 3);
}

export function usePrefersReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] =
useState<boolean>(true);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function updatePrefersReducedMotion() {
setPrefersReducedMotion(() => mediaQuery.matches);
}
mediaQuery.addEventListener('change', updatePrefersReducedMotion);
updatePrefersReducedMotion();
return () =>
mediaQuery.removeEventListener('change', updatePrefersReducedMotion);
}, []);

return prefersReducedMotion;
}
88 changes: 88 additions & 0 deletions test/useRive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,92 @@ describe('useRive', () => {
expect(canvasSpy).toHaveAttribute('width', '200');
expect(canvasSpy).toHaveAttribute('height', '200');
});

it('pauses the animation if usePrefersReducedMotion is passed as true and media query returns true', async () => {
const params = {
src: 'file-src',
};

window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));

const playMock = jest.fn();
const pauseMock = jest.fn();
const stopMock = jest.fn();

const riveMock = {
...baseRiveMock,
stop: stopMock,
play: playMock,
pause: pauseMock,
animationNames: ['light'],
isPlaying: true,
isPaused: false,
};

// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');

const { result } = renderHook(() =>
useRive(params, { usePrefersReducedMotion: true })
);

await act(async () => {
result.current.setCanvasRef(canvasSpy);
controlledRiveloadCb();
});

expect(pauseMock).toBeCalled();
});

it('does not pause the animation if usePrefersReducedMotion is passed as false and media query returns true', async () => {
const params = {
src: 'file-src',
};

window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));

const playMock = jest.fn();
const pauseMock = jest.fn();
const stopMock = jest.fn();

const riveMock = {
...baseRiveMock,
stop: stopMock,
play: playMock,
pause: pauseMock,
animationNames: ['light'],
isPlaying: true,
isPaused: false,
};

// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');

const { result } = renderHook(() =>
useRive(params, { usePrefersReducedMotion: false })
);

await act(async () => {
result.current.setCanvasRef(canvasSpy);
controlledRiveloadCb();
});

expect(pauseMock).not.toBeCalled();
});
});

0 comments on commit 4493636

Please sign in to comment.