Skip to content
Merged
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
79 changes: 18 additions & 61 deletions src/core/PaperProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,47 @@
import * as React from 'react';
import {
AccessibilityInfo,
Appearance,
ColorSchemeName,
NativeEventSubscription,
} from 'react-native';

import { getDefaultDirection, LocaleProvider, type Direction } from './locale';
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 { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../types';
import { addEventListener } from '../utils/addEventListener';

export type Props = {
children: React.ReactNode;
theme?: ThemeProp;
settings?: Settings;
direction?: Direction;
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;

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 colorScheme = useSystemColorScheme(!props.theme);
const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion);

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

return {
...defaultThemeBase,
...props.theme,
animation: {
...props.theme?.animation,
scale: reduceMotionEnabled ? 0 : 1,
scale: resolvedReduceMotion ? 0 : userScale,
},
};
}, [colorScheme, props.theme, reduceMotionEnabled]);
}, [colorScheme, props.theme, resolvedReduceMotion]);

const { children, settings } = props;

Expand All @@ -105,9 +60,11 @@ const PaperProvider = (props: Props) => {
<SafeAreaProviderCompat>
<PortalHost>
<SettingsProvider value={settingsValue}>
<LocaleProvider direction={direction}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</LocaleProvider>
<ReduceMotionContext.Provider value={resolvedReduceMotion}>
<LocaleProvider direction={direction}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</LocaleProvider>
</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);
}
2 changes: 1 addition & 1 deletion src/theme/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
import { $DeepPartial, createTheming } from '@callstack/react-theme-provider';

import { DarkTheme, LightTheme } from './schemes';
import type { InternalTheme, Theme, NavigationTheme } from '../types';
import type { InternalTheme, Theme, NavigationTheme } from './types';

export const DefaultTheme = LightTheme;

Expand Down
Loading