Skip to content
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

Support listening to prefers-reduced-motion setting #163

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
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
29 changes: 26 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,26 @@ 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>(false);

useEffect(() => {
const canListen = typeof window !== 'undefined' && 'matchMedia' in window;
if (!canListen) {
return;
}

const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
avivian marked this conversation as resolved.
Show resolved Hide resolved
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();
});
});