Skip to content

Commit ce6b6b7

Browse files
committed
refactor: add reduceMotion prop and useReduceMotion hook
1 parent 2f8b840 commit ce6b6b7

6 files changed

Lines changed: 183 additions & 79 deletions

File tree

src/core/PaperProvider.tsx

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

93
import { getDefaultDirection, LocaleProvider, type Direction } from './locale';
104
import SafeAreaProviderCompat from './SafeAreaProviderCompat';
115
import { Provider as SettingsProvider, Settings } from './settings';
126
import { defaultThemes, ThemeProvider } from './theming';
7+
import {
8+
useResolvedReduceMotion,
9+
type ReduceMotionPreference,
10+
} from './useResolvedReduceMotion';
11+
import { useSystemColorScheme } from './useSystemColorScheme';
1312
import MaterialCommunityIcon from '../components/MaterialCommunityIcon';
1413
import PortalHost from '../components/Portal/PortalHost';
14+
import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext';
1515
import type { ThemeProp } from '../types';
16-
import { addEventListener } from '../utils/addEventListener';
1716

1817
export type Props = {
1918
children: React.ReactNode;
2019
theme?: ThemeProp;
2120
settings?: Settings;
2221
direction?: Direction;
22+
reduceMotion?: ReduceMotionPreference;
2323
};
2424

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

41-
React.useEffect(() => {
42-
let subscription: NativeEventSubscription | undefined;
43-
44-
if (!props.theme) {
45-
subscription = addEventListener(
46-
AccessibilityInfo,
47-
'reduceMotionChanged',
48-
setReduceMotionEnabled
49-
);
50-
}
51-
return () => {
52-
if (!props.theme) {
53-
subscription?.remove();
54-
}
55-
};
56-
}, [props.theme]);
57-
58-
React.useEffect(() => {
59-
let appearanceSubscription: NativeEventSubscription | undefined;
60-
if (!props.theme) {
61-
appearanceSubscription = Appearance?.addChangeListener(
62-
handleAppearanceChange
63-
) as NativeEventSubscription | undefined;
64-
}
65-
return () => {
66-
if (!props.theme) {
67-
if (appearanceSubscription) {
68-
appearanceSubscription.remove();
69-
} else {
70-
// @ts-expect-error: We keep deprecated listener remove method for backwards compat with old RN versions
71-
Appearance?.removeChangeListener(handleAppearanceChange);
72-
}
73-
}
74-
};
75-
}, [props.theme]);
28+
const colorScheme = useSystemColorScheme(!props.theme);
29+
const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion);
7630

7731
const theme = React.useMemo(() => {
7832
const scheme = colorScheme === 'dark' ? 'dark' : 'light';
7933
const defaultThemeBase = defaultThemes[scheme];
34+
const userScale = props.theme?.animation?.scale ?? 1;
8035

8136
return {
8237
...defaultThemeBase,
8338
...props.theme,
8439
animation: {
8540
...props.theme?.animation,
86-
scale: reduceMotionEnabled ? 0 : 1,
41+
scale: resolvedReduceMotion ? 0 : userScale,
8742
},
8843
};
89-
}, [colorScheme, props.theme, reduceMotionEnabled]);
44+
}, [colorScheme, props.theme, resolvedReduceMotion]);
9045

9146
const { children, settings } = props;
9247

@@ -105,9 +60,11 @@ const PaperProvider = (props: Props) => {
10560
<SafeAreaProviderCompat>
10661
<PortalHost>
10762
<SettingsProvider value={settingsValue}>
108-
<LocaleProvider direction={direction}>
109-
<ThemeProvider theme={theme}>{children}</ThemeProvider>
110-
</LocaleProvider>
63+
<ReduceMotionContext.Provider value={resolvedReduceMotion}>
64+
<LocaleProvider direction={direction}>
65+
<ThemeProvider theme={theme}>{children}</ThemeProvider>
66+
</LocaleProvider>
67+
</ReduceMotionContext.Provider>
11168
</SettingsProvider>
11269
</PortalHost>
11370
</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+
}

src/theme/provider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
33
import { $DeepPartial, createTheming } from '@callstack/react-theme-provider';
44

55
import { DarkTheme, LightTheme } from './schemes';
6-
import type { InternalTheme, Theme, NavigationTheme } from '../types';
6+
import type { InternalTheme, Theme, NavigationTheme } from './types';
77

88
export const DefaultTheme = LightTheme;
99

0 commit comments

Comments
 (0)