Skip to content

Commit a9ec233

Browse files
committed
refactor(PaperProvider): add reduceMotion prop and useReduceMotion hook
1 parent dbc5533 commit a9ec233

5 files changed

Lines changed: 185 additions & 82 deletions

File tree

src/core/PaperProvider.tsx

Lines changed: 21 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,44 @@
11
import * as React from 'react';
2-
import {
3-
AccessibilityInfo,
4-
Appearance,
5-
ColorSchemeName,
6-
NativeEventSubscription,
7-
} from 'react-native';
82

93
import SafeAreaProviderCompat from './SafeAreaProviderCompat';
104
import { Provider as SettingsProvider, Settings } from './settings';
115
import { defaultThemes, ThemeProvider } from './theming';
6+
import {
7+
useResolvedReduceMotion,
8+
type ReduceMotionPreference,
9+
} from './useResolvedReduceMotion';
10+
import { useSystemColorScheme } from './useSystemColorScheme';
1211
import MaterialCommunityIcon from '../components/MaterialCommunityIcon';
1312
import PortalHost from '../components/Portal/PortalHost';
14-
import type { ThemeProp } from '../types';
15-
import { addEventListener } from '../utils/addEventListener';
13+
import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext';
14+
import type { Theme, ThemeProp } from '../types';
1615

1716
export type Props = {
1817
children: React.ReactNode;
1918
theme?: ThemeProp;
2019
settings?: Settings;
20+
reduceMotion?: ReduceMotionPreference;
2121
};
2222

2323
const PaperProvider = (props: Props) => {
24-
const colorSchemeName =
25-
(!props.theme && Appearance?.getColorScheme()) || 'light';
26-
27-
const [reduceMotionEnabled, setReduceMotionEnabled] =
28-
React.useState<boolean>(false);
29-
const [colorScheme, setColorScheme] =
30-
React.useState<ColorSchemeName>(colorSchemeName);
31-
32-
const handleAppearanceChange = (
33-
preferences: Appearance.AppearancePreferences
34-
) => {
35-
const { colorScheme } = preferences;
36-
setColorScheme(colorScheme);
37-
};
24+
const { reduceMotion = 'auto' } = props;
3825

39-
React.useEffect(() => {
40-
let subscription: NativeEventSubscription | undefined;
26+
const colorScheme = useSystemColorScheme(!props.theme);
27+
const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion);
4128

42-
if (!props.theme) {
43-
subscription = addEventListener(
44-
AccessibilityInfo,
45-
'reduceMotionChanged',
46-
setReduceMotionEnabled
47-
);
48-
}
49-
return () => {
50-
if (!props.theme) {
51-
subscription?.remove();
52-
}
53-
};
54-
}, [props.theme]);
55-
56-
React.useEffect(() => {
57-
let appearanceSubscription: NativeEventSubscription | undefined;
58-
if (!props.theme) {
59-
appearanceSubscription = Appearance?.addChangeListener(
60-
handleAppearanceChange
61-
) as NativeEventSubscription | undefined;
62-
}
63-
return () => {
64-
if (!props.theme) {
65-
if (appearanceSubscription) {
66-
appearanceSubscription.remove();
67-
} else {
68-
// @ts-expect-error: We keep deprecated listener remove method for backwards compat with old RN versions
69-
Appearance?.removeChangeListener(handleAppearanceChange);
70-
}
71-
}
72-
};
73-
}, [props.theme]);
74-
75-
const theme = React.useMemo(() => {
29+
const theme = React.useMemo<Theme>(() => {
7630
const scheme = colorScheme === 'dark' ? 'dark' : 'light';
77-
const defaultThemeBase = defaultThemes[scheme];
78-
31+
const base = defaultThemes[scheme];
32+
const userScale = props.theme?.animation?.scale ?? 1;
7933
return {
80-
...defaultThemeBase,
34+
...base,
8135
...props.theme,
8236
animation: {
8337
...props.theme?.animation,
84-
scale: reduceMotionEnabled ? 0 : 1,
38+
scale: resolvedReduceMotion ? 0 : userScale,
8539
},
86-
};
87-
}, [colorScheme, props.theme, reduceMotionEnabled]);
40+
} as Theme;
41+
}, [colorScheme, props.theme, resolvedReduceMotion]);
8842

8943
const { children, settings } = props;
9044

@@ -101,7 +55,9 @@ const PaperProvider = (props: Props) => {
10155
<SafeAreaProviderCompat>
10256
<PortalHost>
10357
<SettingsProvider value={settingsValue}>
104-
<ThemeProvider theme={theme}>{children}</ThemeProvider>
58+
<ReduceMotionContext.Provider value={resolvedReduceMotion}>
59+
<ThemeProvider theme={theme}>{children}</ThemeProvider>
60+
</ReduceMotionContext.Provider>
10561
</SettingsProvider>
10662
</PortalHost>
10763
</SafeAreaProviderCompat>

src/core/__tests__/PaperProvider.test.tsx

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import { render, act } from '@testing-library/react-native';
1010

11+
import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
1112
import { LightTheme, DarkTheme } from '../../theme/schemes';
1213
import type { ThemeProp } from '../../types';
1314
import PaperProvider from '../PaperProvider';
@@ -16,9 +17,7 @@ import { useTheme } from '../theming';
1617
declare module 'react-native' {
1718
interface AccessibilityInfoStatic {
1819
removeEventListener(): void;
19-
__internalListeners: Array<
20-
(options: { reduceMotionEnabled: boolean }) => {}
21-
>;
20+
__internalListeners: Array<(enabled: boolean) => void>;
2221
}
2322

2423
namespace Appearance {
@@ -38,6 +37,7 @@ declare module 'react-native' {
3837

3938
interface ViewProps {
4039
theme?: object;
40+
reduceMotion?: boolean;
4141
}
4242
}
4343

@@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => {
8282
removeEventListener: jest.fn((cb) => {
8383
listeners.push(cb);
8484
}),
85+
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
8586
__internalListeners: listeners,
8687
},
8788
};
@@ -122,36 +123,94 @@ describe('PaperProvider', () => {
122123
);
123124
});
124125

125-
it('should set AccessibilityInfo listeners, if there is no theme', async () => {
126+
it('subscribes to AccessibilityInfo and adapts theme.animation.scale when OS reduce-motion is enabled (auto mode)', async () => {
126127
mockAppearance();
127128
mockAccessibilityInfo();
128129

129-
const { rerender, getByTestId } = render(createProvider());
130+
const { getByTestId } = render(createProvider());
130131

131132
expect(AccessibilityInfo.addEventListener).toHaveBeenCalled();
132-
act(() =>
133-
AccessibilityInfo.__internalListeners[0]({
134-
reduceMotionEnabled: true,
135-
})
136-
);
133+
act(() => AccessibilityInfo.__internalListeners[0](true));
137134

138135
expect(
139136
getByTestId('provider-child-view').props.theme.animation.scale
140137
).toStrictEqual(0);
138+
});
139+
140+
it('exposes the resolved reduce-motion boolean via useReduceMotion to children', async () => {
141+
mockAppearance();
142+
mockAccessibilityInfo();
143+
144+
const Probe = () => {
145+
const reduceMotion = useReduceMotion();
146+
return <View testID="reduce-motion-probe" reduceMotion={reduceMotion} />;
147+
};
148+
149+
const { getByTestId, rerender } = render(
150+
<PaperProvider reduceMotion="on">
151+
<Probe />
152+
</PaperProvider>
153+
);
154+
expect(getByTestId('reduce-motion-probe').props.reduceMotion).toBe(true);
141155

142-
rerender(createProvider(ExtendedLightTheme));
143-
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled();
156+
rerender(
157+
<PaperProvider reduceMotion="off">
158+
<Probe />
159+
</PaperProvider>
160+
);
161+
expect(getByTestId('reduce-motion-probe').props.reduceMotion).toBe(false);
144162
});
145163

146-
it('should not set AccessibilityInfo listeners, if there is a theme', async () => {
164+
it('removes the AccessibilityInfo listener when reduceMotion switches from "auto" to "off"', async () => {
147165
mockAppearance();
148-
const { getByTestId } = render(createProvider(ExtendedDarkTheme));
166+
mockAccessibilityInfo();
149167

150-
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
168+
const { rerender } = render(
169+
<PaperProvider reduceMotion="auto">
170+
<FakeChild />
171+
</PaperProvider>
172+
);
173+
174+
expect(AccessibilityInfo.addEventListener).toHaveBeenCalledTimes(1);
151175
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
152-
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
153-
ExtendedDarkTheme
176+
177+
rerender(
178+
<PaperProvider reduceMotion="off">
179+
<FakeChild />
180+
</PaperProvider>
154181
);
182+
183+
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalledTimes(1);
184+
});
185+
186+
it('does not subscribe to AccessibilityInfo when reduceMotion is "off"', async () => {
187+
mockAppearance();
188+
mockAccessibilityInfo();
189+
const { getByTestId } = render(
190+
<PaperProvider theme={ExtendedDarkTheme} reduceMotion="off">
191+
<FakeChild />
192+
</PaperProvider>
193+
);
194+
195+
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
196+
expect(
197+
getByTestId('provider-child-view').props.theme.animation.scale
198+
).toStrictEqual(1);
199+
});
200+
201+
it('forces animation.scale to 0 when reduceMotion is "on" without subscribing', async () => {
202+
mockAppearance();
203+
mockAccessibilityInfo();
204+
const { getByTestId } = render(
205+
<PaperProvider reduceMotion="on">
206+
<FakeChild />
207+
</PaperProvider>
208+
);
209+
210+
expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
211+
expect(
212+
getByTestId('provider-child-view').props.theme.animation.scale
213+
).toStrictEqual(0);
155214
});
156215

157216
it('should set Appearance listeners, if there is no theme', async () => {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as React from 'react';
2+
import { AccessibilityInfo } from 'react-native';
3+
4+
import { addEventListener } from '../utils/addEventListener';
5+
6+
export type ReduceMotionPreference = 'auto' | 'on' | 'off';
7+
8+
/**
9+
* Resolves a reduce-motion preference into a boolean.
10+
*
11+
* - `'on'` / `'off'` are explicit overrides.
12+
* - `'auto'` subscribes to `AccessibilityInfo.reduceMotionChanged` and follows
13+
* the OS-level setting.
14+
*
15+
* `AccessibilityInfo.isReduceMotionEnabled()` is async, so the first render
16+
* returns `false` for one frame regardless of OS state.
17+
*/
18+
export function useResolvedReduceMotion(
19+
preference: ReduceMotionPreference
20+
): boolean {
21+
const [osReduceMotion, setOsReduceMotion] = React.useState(false);
22+
23+
React.useEffect(() => {
24+
if (preference !== 'auto') return;
25+
let cancelled = false;
26+
27+
const init = async () => {
28+
const v = await AccessibilityInfo.isReduceMotionEnabled?.();
29+
if (!cancelled && v != null) setOsReduceMotion(v);
30+
};
31+
void init();
32+
33+
const sub = addEventListener(
34+
AccessibilityInfo,
35+
'reduceMotionChanged',
36+
setOsReduceMotion
37+
);
38+
return () => {
39+
cancelled = true;
40+
sub.remove();
41+
};
42+
}, [preference]);
43+
44+
return preference === 'auto' ? osReduceMotion : preference === 'on';
45+
}

src/core/useSystemColorScheme.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from 'react';
2+
import { Appearance, ColorSchemeName } from 'react-native';
3+
4+
/**
5+
* Subscribes to the OS color-scheme setting via `Appearance.addChangeListener`
6+
* and returns the current value.
7+
*
8+
* When `enabled` is false the hook does not subscribe and returns `'light'` —
9+
* used by `PaperProvider` to skip system tracking when the user has supplied
10+
* an explicit theme.
11+
*/
12+
export function useSystemColorScheme(enabled: boolean): ColorSchemeName {
13+
const [colorScheme, setColorScheme] = React.useState<ColorSchemeName>(() =>
14+
enabled ? Appearance?.getColorScheme() ?? 'light' : 'light'
15+
);
16+
17+
React.useEffect(() => {
18+
if (!enabled) return;
19+
const sub = Appearance?.addChangeListener((preferences) => {
20+
setColorScheme(preferences.colorScheme);
21+
});
22+
return () => {
23+
sub?.remove();
24+
};
25+
}, [enabled]);
26+
27+
return colorScheme;
28+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as React from 'react';
2+
3+
export const ReduceMotionContext = React.createContext<boolean>(false);
4+
5+
/**
6+
* Returns `true` when the user has requested reduced motion, either via the
7+
* `reduceMotion` prop on `PaperProvider` (`"on"` | `"off"`) or, in `"auto"`
8+
* mode (the default), via the OS-level setting reported by `AccessibilityInfo`.
9+
*
10+
* Use this in component code to gate motion-specific animations (translation,
11+
* scale, transforms) while keeping non-motion animations (opacity, color) intact.
12+
*/
13+
export function useReduceMotion(): boolean {
14+
return React.useContext(ReduceMotionContext);
15+
}

0 commit comments

Comments
 (0)