Skip to content
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
86 changes: 21 additions & 65 deletions src/core/PaperProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
const [colorScheme, setColorScheme] =
React.useState<ColorSchemeName>(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<Theme>(() => {
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;

Expand All @@ -101,7 +55,9 @@ const PaperProvider = (props: Props) => {
<SafeAreaProviderCompat>
<PortalHost>
<SettingsProvider value={settingsValue}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
<ReduceMotionContext.Provider value={resolvedReduceMotion}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ReduceMotionContext.Provider>
</SettingsProvider>
</PortalHost>
</SafeAreaProviderCompat>
Expand Down
93 changes: 76 additions & 17 deletions src/core/__tests__/PaperProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -38,6 +37,7 @@ declare module 'react-native' {

interface ViewProps {
theme?: object;
reduceMotion?: boolean;
}
}

Expand Down Expand Up @@ -82,6 +82,7 @@ const mockAccessibilityInfo = () => {
removeEventListener: jest.fn((cb) => {
listeners.push(cb);
}),
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
__internalListeners: listeners,
},
};
Expand Down Expand Up @@ -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 <View testID="reduce-motion-probe" reduceMotion={reduceMotion} />;
};

const { getByTestId, rerender } = render(
<PaperProvider reduceMotion="on">
<Probe />
</PaperProvider>
);
expect(getByTestId('reduce-motion-probe').props.reduceMotion).toBe(true);

rerender(createProvider(ExtendedLightTheme));
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled();
rerender(
<PaperProvider reduceMotion="off">
<Probe />
</PaperProvider>
);
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(
<PaperProvider reduceMotion="auto">
<FakeChild />
</PaperProvider>
);

expect(AccessibilityInfo.addEventListener).toHaveBeenCalledTimes(1);
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
ExtendedDarkTheme

rerender(
<PaperProvider reduceMotion="off">
<FakeChild />
</PaperProvider>
);

expect(AccessibilityInfo.removeEventListener).toHaveBeenCalledTimes(1);
});

it('does not subscribe to AccessibilityInfo when reduceMotion is "off"', async () => {
mockAppearance();
mockAccessibilityInfo();
const { getByTestId } = render(
<PaperProvider theme={ExtendedDarkTheme} reduceMotion="off">
<FakeChild />
</PaperProvider>
);

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(
<PaperProvider reduceMotion="on">
<FakeChild />
</PaperProvider>
);

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 () => {
Expand Down
45 changes: 45 additions & 0 deletions src/core/useResolvedReduceMotion.ts
Original file line number Diff line number Diff line change
@@ -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';
}
28 changes: 28 additions & 0 deletions src/core/useSystemColorScheme.ts
Original file line number Diff line number Diff line change
@@ -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<ColorSchemeName>(() =>
enabled ? Appearance?.getColorScheme() ?? 'light' : 'light'
);

React.useEffect(() => {
if (!enabled) return;
const sub = Appearance?.addChangeListener((preferences) => {
setColorScheme(preferences.colorScheme);
});
return () => {
sub?.remove();
};
}, [enabled]);

return colorScheme;
}
15 changes: 15 additions & 0 deletions src/theme/accessibility/ReduceMotionContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';

export const ReduceMotionContext = React.createContext<boolean>(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);
}
Loading