From a9ec2338a775e3d8ca7d37d574ef2106b15ae72b Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Wed, 6 May 2026 15:01:43 +0300 Subject: [PATCH] refactor(PaperProvider): add reduceMotion prop and useReduceMotion hook --- src/core/PaperProvider.tsx | 86 +++++------------ src/core/__tests__/PaperProvider.test.tsx | 93 +++++++++++++++---- src/core/useResolvedReduceMotion.ts | 45 +++++++++ src/core/useSystemColorScheme.ts | 28 ++++++ .../accessibility/ReduceMotionContext.tsx | 15 +++ 5 files changed, 185 insertions(+), 82 deletions(-) create mode 100644 src/core/useResolvedReduceMotion.ts create mode 100644 src/core/useSystemColorScheme.ts create mode 100644 src/theme/accessibility/ReduceMotionContext.tsx diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 01a80fdcd9..64c2d45314 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -1,90 +1,44 @@ import * as React from 'react'; -import { - AccessibilityInfo, - Appearance, - ColorSchemeName, - NativeEventSubscription, -} from 'react-native'; import SafeAreaProviderCompat from './SafeAreaProviderCompat'; import { Provider as SettingsProvider, Settings } from './settings'; import { defaultThemes, ThemeProvider } from './theming'; +import { + useResolvedReduceMotion, + type ReduceMotionPreference, +} from './useResolvedReduceMotion'; +import { useSystemColorScheme } from './useSystemColorScheme'; import MaterialCommunityIcon from '../components/MaterialCommunityIcon'; import PortalHost from '../components/Portal/PortalHost'; -import type { ThemeProp } from '../types'; -import { addEventListener } from '../utils/addEventListener'; +import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext'; +import type { Theme, ThemeProp } from '../types'; export type Props = { children: React.ReactNode; theme?: ThemeProp; settings?: Settings; + reduceMotion?: ReduceMotionPreference; }; const PaperProvider = (props: Props) => { - const colorSchemeName = - (!props.theme && Appearance?.getColorScheme()) || 'light'; - - const [reduceMotionEnabled, setReduceMotionEnabled] = - React.useState(false); - const [colorScheme, setColorScheme] = - React.useState(colorSchemeName); - - const handleAppearanceChange = ( - preferences: Appearance.AppearancePreferences - ) => { - const { colorScheme } = preferences; - setColorScheme(colorScheme); - }; + const { reduceMotion = 'auto' } = props; - React.useEffect(() => { - let subscription: NativeEventSubscription | undefined; + const colorScheme = useSystemColorScheme(!props.theme); + const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion); - if (!props.theme) { - subscription = addEventListener( - AccessibilityInfo, - 'reduceMotionChanged', - setReduceMotionEnabled - ); - } - return () => { - if (!props.theme) { - subscription?.remove(); - } - }; - }, [props.theme]); - - React.useEffect(() => { - let appearanceSubscription: NativeEventSubscription | undefined; - if (!props.theme) { - appearanceSubscription = Appearance?.addChangeListener( - handleAppearanceChange - ) as NativeEventSubscription | undefined; - } - return () => { - if (!props.theme) { - if (appearanceSubscription) { - appearanceSubscription.remove(); - } else { - // @ts-expect-error: We keep deprecated listener remove method for backwards compat with old RN versions - Appearance?.removeChangeListener(handleAppearanceChange); - } - } - }; - }, [props.theme]); - - const theme = React.useMemo(() => { + const theme = React.useMemo(() => { const scheme = colorScheme === 'dark' ? 'dark' : 'light'; - const defaultThemeBase = defaultThemes[scheme]; - + const base = defaultThemes[scheme]; + const userScale = props.theme?.animation?.scale ?? 1; return { - ...defaultThemeBase, + ...base, ...props.theme, animation: { ...props.theme?.animation, - scale: reduceMotionEnabled ? 0 : 1, + scale: resolvedReduceMotion ? 0 : userScale, }, - }; - }, [colorScheme, props.theme, reduceMotionEnabled]); + } as Theme; + }, [colorScheme, props.theme, resolvedReduceMotion]); const { children, settings } = props; @@ -101,7 +55,9 @@ const PaperProvider = (props: Props) => { - {children} + + {children} + diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 7b9b554ff7..d94d45b9f7 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -8,6 +8,7 @@ import { import { render, act } from '@testing-library/react-native'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { LightTheme, DarkTheme } from '../../theme/schemes'; import type { ThemeProp } from '../../types'; import PaperProvider from '../PaperProvider'; @@ -16,9 +17,7 @@ import { useTheme } from '../theming'; declare module 'react-native' { interface AccessibilityInfoStatic { removeEventListener(): void; - __internalListeners: Array< - (options: { reduceMotionEnabled: boolean }) => {} - >; + __internalListeners: Array<(enabled: boolean) => void>; } namespace Appearance { @@ -38,6 +37,7 @@ declare module 'react-native' { interface ViewProps { theme?: object; + reduceMotion?: boolean; } } @@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => { removeEventListener: jest.fn((cb) => { listeners.push(cb); }), + isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)), __internalListeners: listeners, }, }; @@ -122,36 +123,94 @@ describe('PaperProvider', () => { ); }); - it('should set AccessibilityInfo listeners, if there is no theme', async () => { + it('subscribes to AccessibilityInfo and adapts theme.animation.scale when OS reduce-motion is enabled (auto mode)', async () => { mockAppearance(); mockAccessibilityInfo(); - const { rerender, getByTestId } = render(createProvider()); + const { getByTestId } = render(createProvider()); expect(AccessibilityInfo.addEventListener).toHaveBeenCalled(); - act(() => - AccessibilityInfo.__internalListeners[0]({ - reduceMotionEnabled: true, - }) - ); + act(() => AccessibilityInfo.__internalListeners[0](true)); expect( getByTestId('provider-child-view').props.theme.animation.scale ).toStrictEqual(0); + }); + + it('exposes the resolved reduce-motion boolean via useReduceMotion to children', async () => { + mockAppearance(); + mockAccessibilityInfo(); + + const Probe = () => { + const reduceMotion = useReduceMotion(); + return ; + }; + + const { getByTestId, rerender } = render( + + + + ); + expect(getByTestId('reduce-motion-probe').props.reduceMotion).toBe(true); - rerender(createProvider(ExtendedLightTheme)); - expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled(); + rerender( + + + + ); + expect(getByTestId('reduce-motion-probe').props.reduceMotion).toBe(false); }); - it('should not set AccessibilityInfo listeners, if there is a theme', async () => { + it('removes the AccessibilityInfo listener when reduceMotion switches from "auto" to "off"', async () => { mockAppearance(); - const { getByTestId } = render(createProvider(ExtendedDarkTheme)); + mockAccessibilityInfo(); - expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled(); + const { rerender } = render( + + + + ); + + expect(AccessibilityInfo.addEventListener).toHaveBeenCalledTimes(1); expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled(); - expect(getByTestId('provider-child-view').props.theme).toStrictEqual( - ExtendedDarkTheme + + rerender( + + + ); + + expect(AccessibilityInfo.removeEventListener).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe to AccessibilityInfo when reduceMotion is "off"', async () => { + mockAppearance(); + mockAccessibilityInfo(); + const { getByTestId } = render( + + + + ); + + expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled(); + expect( + getByTestId('provider-child-view').props.theme.animation.scale + ).toStrictEqual(1); + }); + + it('forces animation.scale to 0 when reduceMotion is "on" without subscribing', async () => { + mockAppearance(); + mockAccessibilityInfo(); + const { getByTestId } = render( + + + + ); + + expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled(); + expect( + getByTestId('provider-child-view').props.theme.animation.scale + ).toStrictEqual(0); }); it('should set Appearance listeners, if there is no theme', async () => { diff --git a/src/core/useResolvedReduceMotion.ts b/src/core/useResolvedReduceMotion.ts new file mode 100644 index 0000000000..fe264e54fd --- /dev/null +++ b/src/core/useResolvedReduceMotion.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import { addEventListener } from '../utils/addEventListener'; + +export type ReduceMotionPreference = 'auto' | 'on' | 'off'; + +/** + * Resolves a reduce-motion preference into a boolean. + * + * - `'on'` / `'off'` are explicit overrides. + * - `'auto'` subscribes to `AccessibilityInfo.reduceMotionChanged` and follows + * the OS-level setting. + * + * `AccessibilityInfo.isReduceMotionEnabled()` is async, so the first render + * returns `false` for one frame regardless of OS state. + */ +export function useResolvedReduceMotion( + preference: ReduceMotionPreference +): boolean { + const [osReduceMotion, setOsReduceMotion] = React.useState(false); + + React.useEffect(() => { + if (preference !== 'auto') return; + let cancelled = false; + + const init = async () => { + const v = await AccessibilityInfo.isReduceMotionEnabled?.(); + if (!cancelled && v != null) setOsReduceMotion(v); + }; + void init(); + + const sub = addEventListener( + AccessibilityInfo, + 'reduceMotionChanged', + setOsReduceMotion + ); + return () => { + cancelled = true; + sub.remove(); + }; + }, [preference]); + + return preference === 'auto' ? osReduceMotion : preference === 'on'; +} diff --git a/src/core/useSystemColorScheme.ts b/src/core/useSystemColorScheme.ts new file mode 100644 index 0000000000..11501db016 --- /dev/null +++ b/src/core/useSystemColorScheme.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Appearance, ColorSchemeName } from 'react-native'; + +/** + * Subscribes to the OS color-scheme setting via `Appearance.addChangeListener` + * and returns the current value. + * + * When `enabled` is false the hook does not subscribe and returns `'light'` — + * used by `PaperProvider` to skip system tracking when the user has supplied + * an explicit theme. + */ +export function useSystemColorScheme(enabled: boolean): ColorSchemeName { + const [colorScheme, setColorScheme] = React.useState(() => + enabled ? Appearance?.getColorScheme() ?? 'light' : 'light' + ); + + React.useEffect(() => { + if (!enabled) return; + const sub = Appearance?.addChangeListener((preferences) => { + setColorScheme(preferences.colorScheme); + }); + return () => { + sub?.remove(); + }; + }, [enabled]); + + return colorScheme; +} diff --git a/src/theme/accessibility/ReduceMotionContext.tsx b/src/theme/accessibility/ReduceMotionContext.tsx new file mode 100644 index 0000000000..34ad809060 --- /dev/null +++ b/src/theme/accessibility/ReduceMotionContext.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export const ReduceMotionContext = React.createContext(false); + +/** + * Returns `true` when the user has requested reduced motion, either via the + * `reduceMotion` prop on `PaperProvider` (`"on"` | `"off"`) or, in `"auto"` + * mode (the default), via the OS-level setting reported by `AccessibilityInfo`. + * + * Use this in component code to gate motion-specific animations (translation, + * scale, transforms) while keeping non-motion animations (opacity, color) intact. + */ +export function useReduceMotion(): boolean { + return React.useContext(ReduceMotionContext); +}