Skip to content
56 changes: 56 additions & 0 deletions docs/6.x/docs/guides/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,62 @@ export default function Main() {
}
```

## Contrast levels

Material Design 3 defines three contrast levels, `standard`, `medium` and `high`. The higher levels increase the contrast between text and background colors, which helps users with low vision and makes the app easier to read in bright light.

Each level is an exported theme that you pass to `PaperProvider`, the same way as the default themes:

```js
import * as React from 'react';
import { PaperProvider, HighContrastLightTheme } from 'react-native-paper';

export default function Main() {
return (
<PaperProvider theme={HighContrastLightTheme}>
<App />
</PaperProvider>
);
}
```

The available themes are:

- `LightTheme` and `DarkTheme`
- `MediumContrastLightTheme` and `MediumContrastDarkTheme`
- `HighContrastLightTheme` and `HighContrastDarkTheme`

The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every text and background role pair.

Note that passing a `theme` turns off automatic system dark mode, so pick the theme yourself when you follow the system setting:

```js
import { useColorScheme } from 'react-native';
import {
PaperProvider,
HighContrastDarkTheme,
HighContrastLightTheme,
} from 'react-native-paper';

export default function Main() {
const isDarkMode = useColorScheme() === 'dark';

return (
<PaperProvider
theme={isDarkMode ? HighContrastDarkTheme : HighContrastLightTheme}
>
<App />
</PaperProvider>
);
}
```

### Contrast and dynamic colors

On Android 14 (API 34) and newer, `DynamicLightTheme` and `DynamicDarkTheme` follow the contrast level the user picks in the system Color contrast setting. Your app can't ask the system for a different level.

If your app needs a fixed raised level, use the static contrast themes instead. They don't use the wallpaper colors.

## Adapting React Navigation theme

The `adaptNavigationTheme` function takes an existing React Navigation theme and returns a React Navigation theme using the colors from Material Design 3. This theme can be passed to `NavigationContainer` so that React Navigation's UI elements have the same color scheme as Paper.
Expand Down
36 changes: 35 additions & 1 deletion example/src/DrawerItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Dialog,
Drawer,
Palette,
SegmentedButtons,
Switch,
Text,
TouchableRipple,
Expand All @@ -24,7 +25,11 @@ const DrawerItemsData = [
label: 'Inbox',
icon: 'inbox',
key: 0,
right: () => <Text variant="labelLarge">44</Text>,
right: ({ color }: { color: ColorValue }) => (
<Text variant="labelLarge" style={{ color }}>
44
</Text>
),
},
{
label: 'Starred',
Expand Down Expand Up @@ -103,9 +108,11 @@ function DrawerItems() {
toggleCollapsed,
toggleCustomFont,
toggleRippleEffect,
setContrast,
customFontLoaded,
rippleEffectEnabled,
collapsed,
contrast,
rtl: isRTL,
theme: { dark: isDarkTheme },
shouldUseDynamicTheme,
Expand Down Expand Up @@ -190,6 +197,24 @@ function DrawerItems() {
</View>
</TouchableRipple>

{dynamicThemeSupported && shouldUseDynamicTheme ? null : (
<View style={[styles.preference, styles.contrastPreference]}>
<Text variant="labelLarge" style={styles.contrastLabel}>
Contrast
</Text>
<SegmentedButtons
value={contrast}
onValueChange={(value) => setContrast(value)}
density="small"
buttons={[
{ value: 'standard', label: 'Standard' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
]}
/>
</View>
)}

<TouchableRipple onPress={_handleToggleRTL}>
<View style={[styles.preference, styles.v3Preference]}>
<Text variant="labelLarge">RTL</Text>
Expand Down Expand Up @@ -274,6 +299,15 @@ const styles = StyleSheet.create({
height: 56,
paddingHorizontal: 28,
},
contrastPreference: {
flexDirection: 'column',
alignItems: 'stretch',
gap: 12,
paddingHorizontal: 16,
},
contrastLabel: {
paddingHorizontal: 12,
},
badge: {
alignSelf: 'center',
},
Expand Down
4 changes: 4 additions & 0 deletions example/src/PreferencesContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import * as React from 'react';

import type { Theme } from 'react-native-paper';

type ContrastLevel = 'standard' | 'medium' | 'high';

export const PreferencesContext = React.createContext<{
toggleTheme: () => void;
toggleRtl: () => void;
toggleCollapsed: () => void;
toggleCustomFont: () => void;
toggleRippleEffect: () => void;
toggleShouldUseDynamicTheme?: () => void;
setContrast: (contrast: ContrastLevel) => void;
theme: Theme;
contrast: ContrastLevel;
rtl: boolean;
collapsed: boolean;
customFontLoaded: boolean;
Expand Down
50 changes: 40 additions & 10 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
import {
PaperProvider,
DarkTheme,
LightTheme,
DynamicLightTheme,
DynamicDarkTheme,
DynamicLightTheme,
HighContrastDarkTheme,
HighContrastLightTheme,
LightTheme,
MediumContrastDarkTheme,
MediumContrastLightTheme,
PaperProvider,
} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

Expand All @@ -26,12 +30,26 @@ import { PreferencesContext } from './PreferencesContext';
import App from './RootNavigator';
import { dynamicThemeSupported } from '../utils';
import {
CombinedDarkTheme,
CombinedDefaultTheme,
createCombinedTheme,
createConfiguredFontNavigationTheme,
createConfiguredFontTheme,
} from '../utils/themes';

type ContrastLevel = 'standard' | 'medium' | 'high';

const THEMES = {
light: {
standard: LightTheme,
medium: MediumContrastLightTheme,
high: HighContrastLightTheme,
},
dark: {
standard: DarkTheme,
medium: MediumContrastDarkTheme,
high: HighContrastDarkTheme,
},
};

const PERSISTENCE_KEY = 'NAVIGATION_STATE';
const PREFERENCES_KEY = 'APP_PREFERENCES';

Expand Down Expand Up @@ -98,15 +116,16 @@ export default function PaperExample() {
const [collapsed, setCollapsed] = React.useState(false);
const [customFontLoaded, setCustomFont] = React.useState(false);
const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true);
const [contrast, setContrast] = React.useState<ContrastLevel>('standard');

// Dynamic colors follow the system contrast setting, so the contrast
// preference only applies to the static themes.
const theme =
dynamicThemeSupported && shouldUseDynamicTheme
? isDarkMode
? DynamicDarkTheme
: DynamicLightTheme
: isDarkMode
? DarkTheme
: LightTheme;
: THEMES[isDarkMode ? 'dark' : 'light'][contrast];

const direction = rtl ? 'rtl' : 'ltr';

Expand All @@ -122,6 +141,13 @@ export default function PaperExample() {
if (typeof preferences.rtl === 'boolean') {
setRtl(preferences.rtl);
}

if (
preferences.contrast === 'medium' ||
preferences.contrast === 'high'
) {
setContrast(preferences.contrast);
}
}
} catch (e) {
// ignore error
Expand All @@ -145,6 +171,7 @@ export default function PaperExample() {
JSON.stringify({
theme: isDarkMode ? 'dark' : 'light',
rtl,
contrast,
})
);
} catch (e) {
Expand All @@ -165,7 +192,7 @@ export default function PaperExample() {
};

void savePrefs();
}, [direction, isDarkMode, isReady, rtl]);
}, [contrast, direction, isDarkMode, isReady, rtl]);

const preferences = React.useMemo(
() => ({
Expand All @@ -176,9 +203,11 @@ export default function PaperExample() {
toggleCollapsed: () => setCollapsed((oldValue) => !oldValue),
toggleCustomFont: () => setCustomFont((oldValue) => !oldValue),
toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue),
setContrast,
customFontLoaded,
rippleEffectEnabled,
shouldUseDynamicTheme,
contrast,
theme,
collapsed,
rtl,
Expand All @@ -187,6 +216,7 @@ export default function PaperExample() {
rtl,
theme,
collapsed,
contrast,
customFontLoaded,
shouldUseDynamicTheme,
rippleEffectEnabled,
Expand All @@ -197,7 +227,7 @@ export default function PaperExample() {
return null;
}

const combinedTheme = isDarkMode ? CombinedDarkTheme : CombinedDefaultTheme;
const combinedTheme = createCombinedTheme(theme, isDarkMode);
const configuredFontTheme = createConfiguredFontTheme(combinedTheme);
const configuredFontNavigationTheme =
createConfiguredFontNavigationTheme(combinedTheme);
Expand Down
62 changes: 28 additions & 34 deletions example/utils/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,38 @@ import {
DefaultTheme as NavigationDefaultTheme,
} from '@react-navigation/native';
import type { Theme as ReactNavigationTheme } from '@react-navigation/native';
import {
adaptNavigationTheme,
DarkTheme,
LightTheme,
configureFonts,
} from 'react-native-paper';
import { adaptNavigationTheme, configureFonts } from 'react-native-paper';
import type { Theme } from 'react-native-paper';

const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } =
adaptNavigationTheme({
reactNavigationLight: NavigationDefaultTheme,
reactNavigationDark: NavigationDarkTheme,
});
/**
* Merges the React Navigation theme into a Paper theme.
*
* The Paper theme is passed in, and also given to `adaptNavigationTheme`, so
* that the selected contrast level is kept.
*/
export const createCombinedTheme = (paperTheme: Theme, isDark: boolean) => {
const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } =
adaptNavigationTheme({
reactNavigationLight: NavigationDefaultTheme,
reactNavigationDark: NavigationDarkTheme,
materialLight: isDark ? undefined : paperTheme,
materialDark: isDark ? paperTheme : undefined,
});

export const CombinedDefaultTheme = {
...LightTheme,
...NavLightTheme,
colors: {
...LightTheme.colors,
...NavLightTheme.colors,
},
fonts: {
...LightTheme.fonts,
...NavLightTheme.fonts,
},
};
const navTheme = isDark ? NavDarkTheme : NavLightTheme;

export const CombinedDarkTheme = {
...DarkTheme,
...NavDarkTheme,
colors: {
...DarkTheme.colors,
...NavDarkTheme.colors,
},
fonts: {
...DarkTheme.fonts,
...NavDarkTheme.fonts,
},
return {
...paperTheme,
...navTheme,
colors: {
...paperTheme.colors,
...navTheme.colors,
},
fonts: {
...paperTheme.fonts,
...navTheme.fonts,
},
};
};

export const createConfiguredFontTheme = (
Expand Down
28 changes: 27 additions & 1 deletion src/core/__tests__/theming.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from '@jest/globals';

import { DarkTheme, LightTheme } from '../../theme/schemes';
import {
DarkTheme,
HighContrastDarkTheme,
HighContrastLightTheme,
LightTheme,
} from '../../theme/schemes';
import { adaptNavigationTheme } from '../theming';

const NavigationLightTheme = {
Expand Down Expand Up @@ -273,4 +278,25 @@ describe('adaptNavigationTheme', () => {
expect(navLight).not.toHaveProperty('fonts');
expect(navDark).not.toHaveProperty('fonts');
});

it('adapts the colors of a raised-contrast material theme', () => {
const materialLight = HighContrastLightTheme;
const materialDark = HighContrastDarkTheme;

const { LightTheme: navLight, DarkTheme: navDark } = adaptNavigationTheme({
reactNavigationLight: NavigationLightTheme,
reactNavigationDark: NavigationDarkTheme,
materialLight,
materialDark,
});

// Apps spread the navigation colors over the Paper theme, so these
// must match the contrast level that was asked for.
expect(navLight.colors.primary).toBe(materialLight.colors.primary);
expect(navLight.colors.text).toBe(materialLight.colors.onSurface);
expect(navDark.colors.primary).toBe(materialDark.colors.primary);

expect(navLight.colors.primary).not.toBe(LightTheme.colors.primary);
expect(navDark.colors.primary).not.toBe(DarkTheme.colors.primary);
});
});
Loading
Loading