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
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ exports[`renders list section with custom title style 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down Expand Up @@ -1172,6 +1173,7 @@ exports[`renders list section with subheader 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down Expand Up @@ -1961,6 +1963,7 @@ exports[`renders list section without subheader 1`] = `
1,
],
},
"prefersReducedMotion": false,
"spring": {
"default": {
"effects": {
Expand Down
59 changes: 23 additions & 36 deletions src/core/PaperProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
import * as React from 'react';
import {
AccessibilityInfo,
Appearance,
ColorSchemeName,
NativeEventSubscription,
} from 'react-native';
import { Appearance, ColorSchemeName } from 'react-native';

import SafeAreaProviderCompat from './SafeAreaProviderCompat';
import { Provider as SettingsProvider, Settings } from './settings';
import { defaultThemes, ThemeProvider } from './theming';
import MaterialCommunityIcon from '../components/MaterialCommunityIcon';
import PortalHost from '../components/Portal/PortalHost';
import type { ThemeProp } from '../types';
import { addEventListener } from '../utils/addEventListener';
import { useAccessibleTheme } from '../theme/accessibility';
import type { Theme, ThemeProp } from '../types';

export type Props = {
children: React.ReactNode;
theme?: ThemeProp;
settings?: Settings;
/**
* Whether OS-level accessibility preferences (reduce motion) are automatically
* reflected in the theme. Defaults to `true`. Set to `false` to handle
* accessibility in your own code.
*/
accessibilityAdapters?: boolean;
};

const PaperProvider = (props: Props) => {
const { accessibilityAdapters = true } = props;

const colorSchemeName =
(!props.theme && Appearance?.getColorScheme()) || 'light';

const [reduceMotionEnabled, setReduceMotionEnabled] =
React.useState<boolean>(false);
const [colorScheme, setColorScheme] =
React.useState<ColorSchemeName>(colorSchemeName);

Expand All @@ -37,28 +38,13 @@ const PaperProvider = (props: 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;
let appearanceSubscription:
| ReturnType<typeof Appearance.addChangeListener>
| undefined;
if (!props.theme) {
appearanceSubscription = Appearance?.addChangeListener(
handleAppearanceChange
) as NativeEventSubscription | undefined;
) as typeof appearanceSubscription;
}
return () => {
if (!props.theme) {
Expand All @@ -72,19 +58,20 @@ const PaperProvider = (props: Props) => {
};
}, [props.theme]);

const theme = React.useMemo(() => {
const rawTheme = React.useMemo(() => {
const scheme = colorScheme === 'dark' ? 'dark' : 'light';
const defaultThemeBase = defaultThemes[scheme];

const base = defaultThemes[scheme];
return {
...defaultThemeBase,
...base,
...props.theme,
animation: {
...props.theme?.animation,
scale: reduceMotionEnabled ? 0 : 1,
scale: props.theme?.animation?.scale ?? 1,
},
};
}, [colorScheme, props.theme, reduceMotionEnabled]);
} as Theme;
}, [colorScheme, props.theme]);

const theme = useAccessibleTheme(rawTheme, accessibilityAdapters !== false);

const { children, settings } = props;

Expand Down
18 changes: 10 additions & 8 deletions src/core/__tests__/PaperProvider.test.tsx
Original file line number Diff line number Diff line change
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 @@ -136,11 +137,11 @@ describe('PaperProvider', () => {
});
});

it('should set AccessibilityInfo listeners, if there is no theme', async () => {
it('should set AccessibilityInfo listeners and adapt theme when reduce motion is enabled', async () => {
mockAppearance();
mockAccessibilityInfo();

const { rerender, getByTestId } = render(createProvider());
const { getByTestId } = render(createProvider());

expect(AccessibilityInfo.addEventListener).toHaveBeenCalled();
act(() =>
Expand All @@ -152,17 +153,18 @@ describe('PaperProvider', () => {
expect(
getByTestId('provider-child-view').props.theme.animation.scale
).toStrictEqual(0);

rerender(createProvider(ExtendedLightTheme));
expect(AccessibilityInfo.removeEventListener).toHaveBeenCalled();
});

it('should not set AccessibilityInfo listeners, if there is a theme', async () => {
it('should not set AccessibilityInfo listeners when accessibilityAdapters is false', async () => {
mockAppearance();
const { getByTestId } = render(createProvider(ExtendedDarkTheme));
mockAccessibilityInfo();
const { getByTestId } = render(
<PaperProvider theme={ExtendedDarkTheme} accessibilityAdapters={false}>
<FakeChild />
</PaperProvider>
);

expect(AccessibilityInfo.addEventListener).not.toHaveBeenCalled();
expect(AccessibilityInfo.removeEventListener).not.toHaveBeenCalled();
expect(getByTestId('provider-child-view').props.theme).toStrictEqual(
ExtendedDarkTheme
);
Expand Down
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ export { cornersToStyle } from './theme/tokens/sys/shape';
export {
expressiveMotion,
standardMotion,
reducedMotion,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it's necessary to expose this.

toRawSpring,
} from './theme/tokens/sys/motion';

export { useAccessibleTheme } from './theme/accessibility';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think this belongs as a public API. We should only export what's absolutely necessary. Avoid expanding the API surface unless really needed.


import * as Avatar from './components/Avatar/Avatar';
import * as Drawer from './components/Drawer/Drawer';
import * as List from './components/List/List';
Expand Down
1 change: 1 addition & 0 deletions src/theme/accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useAccessibleTheme } from './useAccessibleTheme';
48 changes: 48 additions & 0 deletions src/theme/accessibility/useAccessibleTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from 'react';
import { AccessibilityInfo } from 'react-native';

import { addEventListener } from '../../utils/addEventListener';
import { reducedMotion } from '../tokens/sys/motion';
import type { Theme } from '../types';

function applyReducedMotion(theme: Theme): Theme {
return {
...theme,
animation: { ...theme.animation, scale: 0 },
motion: reducedMotion,
};
}

export function useAccessibleTheme(theme: Theme, enabled = true): Theme {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be wrapping theme in useTheme. This causes every component using this hook to subscribe to reduce motion.

If this is handled, then it should be done in PaperProvider in a single place so there's a single subscription.

But also, if prefersReducedMotion is in the theme, it conflicts with such an API if it's also handled internally. When the user passes a theme, which will already have prefersReducedMotion, is it also handled internally and overridden?

It probably makes sense to expose a separate prop reduceMotion="auto" or something like that on PaperProvider, then internally it can subscribe and expose the actual value in context. Then, on the component level, components can adapt their animations to reduce motion when necessary.

There is also the problem of the value not being known on initial render, as it's async, so if some component has animation on render, it won't properly respect this.

const [reduceMotion, setReduceMotion] = React.useState(false);

React.useEffect(() => {
if (!enabled) return;

let cancelled = false;

const init = async () => {
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled?.();
if (!cancelled && reduceMotion !== undefined)
setReduceMotion(reduceMotion);
};

void init();

const motionSub = addEventListener(
AccessibilityInfo,
'reduceMotionChanged',
setReduceMotion
);

return () => {
cancelled = true;
motionSub.remove();
};
}, [enabled]);

return React.useMemo(() => {
if (!enabled) return theme;
return reduceMotion ? applyReducedMotion(theme) : theme;
}, [theme, reduceMotion, enabled]);
}
32 changes: 32 additions & 0 deletions src/theme/tokens/sys/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,44 @@ export const expressiveMotion: MotionConfig = {
...expressiveSpring,
easing: motionEasing,
duration: motionDuration,
prefersReducedMotion: false,
};

export const standardMotion: MotionConfig = {
...standardSpring,
easing: motionEasing,
duration: motionDuration,
prefersReducedMotion: false,
};

const instantSpring = { stiffness: 10000, damping: 1 };

export const reducedMotion: MotionConfig = {
spring: {
fast: { spatial: instantSpring, effects: instantSpring },
default: { spatial: instantSpring, effects: instantSpring },
slow: { spatial: instantSpring, effects: instantSpring },
},
easing: motionEasing,
prefersReducedMotion: true,
duration: {
short1: 0,
short2: 0,
short3: 0,
short4: 0,
medium1: 0,
medium2: 0,
medium3: 0,
medium4: 0,
long1: 0,
long2: 0,
long3: 0,
long4: 0,
extraLong1: 0,
extraLong2: 0,
extraLong3: 0,
extraLong4: 0,
},
};
Comment on lines +93 to 119
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure setting all of these to 0/instant animation makes sense for reduce motion. Reduce motion is about reducing motion (e.g., movement, like translation, scale, etc.). Simple animations like opacity and colors should still work. It shouldn't disable animations entirely.

It probably makes more sense to handle it at the component level, because how each component changes its animation in response to this depends on the component, e.g. menu should change to simple fade instead of scale etc.


/**
Expand Down
1 change: 1 addition & 0 deletions src/theme/types/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ export type MotionConfig = {
spring: MotionSpring;
easing: MotionEasing;
duration: MotionDuration;
prefersReducedMotion: boolean;
};
6 changes: 5 additions & 1 deletion src/theme/types/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ import type { ThemeShapes } from './shape';
import type { ThemeState } from './state';
import type { Typescale } from './typography';

/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */
type Mode = 'adaptive' | 'exact';

export type ThemeBase = {
dark: boolean;
/** @deprecated Will be removed in a future version. MD3 uses tonal surface colors via `theme.colors.elevation.*`. */
mode?: Mode;
/** @deprecated Use `theme.shapes.*` instead. Will be removed in a future version. */
roundness: number;
/** @deprecated Use `theme.motion.*` instead. Will be removed in a future version. */
animation: {
/** @deprecated Use `theme.motion.prefersReducedMotion` instead. Will be removed in a future version. */
scale: number;
/** @deprecated Use `theme.motion.duration.*` instead. Will be removed in a future version. */
/** @deprecated No-op. Use `theme.motion.duration.*` instead. Will be removed in a future version. */
defaultAnimationDuration?: number;
Comment on lines +15 to 24
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove deprecated theme properties. We don't want another situation like V2/v3 themes.

If necessary, the migration guide can show a helper that converts old theme to new theme for easier migration.

};
};
Expand Down
Loading