diff --git a/docs/pages/2.theming.md b/docs/pages/2.theming.md index 43b626d9b3..ffa81a3e3c 100644 --- a/docs/pages/2.theming.md +++ b/docs/pages/2.theming.md @@ -226,6 +226,46 @@ Passed source color into the util is translated into tones to automatically prov Source: [Material You Color System](https://m3.material.io/styles/color/the-color-system/custom-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. + +```ts +adaptNavigationTheme(params) +``` + +Parameters: + +| NAME | TYPE | +| ----------- | ----------- | +| params | object | + +Valid `params` keys are: + + * `light` () - React Navigation compliant light theme. + * `dark` () - React Navigation compliant dark theme. + +```ts +// App.tsx +import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { Provider, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper'; +const Stack = createStackNavigator(); +const { LightTheme } = adaptNavigationTheme({ light: DefaultTheme }); +export default function App() { + return ( + + + + + + + + + ); +} +``` + ## TypeScript By default extending the theme won't work well with TypeScript, but we can take advantage of `global augmentations` and specify the new properties that we added to the theme: @@ -400,4 +440,4 @@ Otherwise, your custom theme will need to handle it manually, using React Native The `Provider` exposes the theme to the components via [React's context API](https://reactjs.org/docs/context.html), which means that the component must be in the same tree as the `Provider`. Some React Native components will render a different tree such as a `Modal`, in which case the components inside the `Modal` won't be able to access the theme. The work around is to get the theme using the `withTheme` HOC and pass it down to the components as props, or expose it again with the exported `ThemeProvider` component. -The `Modal` component from the library already handles this edge case, so you won't need to do anything. +The `Modal` component from the library already handles this edge case, so you won't need to do anything. \ No newline at end of file diff --git a/docs/pages/8.theming-with-react-navigation.md b/docs/pages/8.theming-with-react-navigation.md index c2a94389eb..14901311c7 100644 --- a/docs/pages/8.theming-with-react-navigation.md +++ b/docs/pages/8.theming-with-react-navigation.md @@ -9,6 +9,51 @@ In this guide we will look into how to apply theming for an application using Re Offering different theme options, especially dark/light ones, becomes increasingly a standard requirement of the modern mobile application. Fortunately, both React Navigation and React Native Paper support configurable theming out-of-the-box. But how to make them work together? +## Themes adaptation + +### Material Design 2 + +Fortunately, in Material Design 2, both React Navigation and React Native Paper offer very similar API when it comes to theming and theme color structure. It's possible to import them in light and dark variants from both. + +```js +import { + DarkTheme as NavigationDarkTheme, + DefaultTheme as NavigationDefaultTheme, +} from '@react-navigation/native'; + +import { + MD2LightTheme, + MD2DarkTheme, +} from 'react-native-paper'; +``` + +### Material Design 3 + +From v5, React Native Paper theme colors structure is following the Material Design 3 (known as Material You) colors system, which differs significantly from both previous Paper's theme and React Navigation theme. + +However, to simplify adapting React Navigation theme colors, to use the ones from React Native Paper, it's worth using a utility called `adaptNavigationTheme` – it accepts navigation compliant themes in both modes and returns their equivalents adjusted to Material Design 3. + +```ts +import { + DarkTheme as NavigationDarkTheme, + DefaultTheme as NavigationDefaultTheme, +} from '@react-navigation/native'; + +const { LightTheme, DarkTheme } = adaptNavigationTheme({ + light: NavigationDefaultTheme, + dark: NavigationDarkTheme, +}); +``` + +Library exports also Material Design 3 themes in both modes: + +```js +import { + MD3LightTheme, + MD3DarkTheme, +} from 'react-native-paper'; +``` + ## Combining theme objects Both libraries require a wrapper to be used at the entry point of the application. @@ -71,9 +116,7 @@ export default function App() { } ``` -Fortunately, both React Navigation and React Native Paper offer very similar API when it comes to theming. It's possible to import default themes in light and dark variants from both. -React Navigation and React Native Paper use the same name for default themes - `DefaultTheme` and `DarkTheme`, so we need to alias them at the imports. Our goal here is to combine those two themes, so that we could control the theme for the entire application from a single place. @@ -83,6 +126,8 @@ To make things easier we can use [deepmerge](https://www.npmjs.com/package/deepm yarn add deepmerge ``` +### Material Design 2 + ```js import { NavigationContainer, @@ -90,37 +135,87 @@ import { DefaultTheme as NavigationDefaultTheme, } from '@react-navigation/native'; import { - DarkTheme as PaperDarkTheme, - DefaultTheme as PaperDefaultTheme, - Provider as PaperProvider, + MD2DarkTheme, + MD2LightTheme, } from 'react-native-paper'; import merge from 'deepmerge'; -const CombinedDefaultTheme = merge(PaperDefaultTheme, NavigationDefaultTheme); -const CombinedDarkTheme = merge(PaperDarkTheme, NavigationDarkTheme); +const CombinedDefaultTheme = merge(MD2DarkTheme, NavigationDefaultTheme); +const CombinedDarkTheme = merge(MD2LightTheme, NavigationDarkTheme); ``` -Alternatively, we could merge those themes using vanilla JavaScript +### Material Design 3 + +```js +import { + NavigationContainer, + DarkTheme as NavigationDarkTheme, + DefaultTheme as NavigationDefaultTheme, +} from '@react-navigation/native'; +import { + MD3DarkTheme, + MD3LightTheme, +} from 'react-native-paper'; +import merge from 'deepmerge'; + +const { LightTheme, DarkTheme } = adaptNavigationTheme({ + light: NavigationDefaultTheme, + dark: NavigationDarkTheme, +}); + +const CombinedDefaultTheme = merge(MD2DarkTheme, LightTheme); +const CombinedDarkTheme = merge(MD2LightTheme, DarkTheme); +``` + +Alternatively, we could merge those themes using vanilla JavaScript: + +### Material Design 2 ```js const CombinedDefaultTheme = { - ...PaperDefaultTheme, + ...MD2LightTheme, ...NavigationDefaultTheme, colors: { - ...PaperDefaultTheme.colors, + ...MD2LightTheme.colors, ...NavigationDefaultTheme.colors, }, }; const CombinedDarkTheme = { - ...PaperDarkTheme, + ...MD2DarkTheme, ...NavigationDarkTheme, colors: { - ...PaperDarkTheme.colors, + ...MD2DarkTheme.colors, ...NavigationDarkTheme.colors, }, }; ``` +### Material Design 3 + +```js +const { LightTheme, DarkTheme } = adaptNavigationTheme({ + light: NavigationDefaultTheme, + dark: NavigationDarkTheme, +}); + +const CombinedDefaultTheme = { + ...MD3LightTheme, + ...LightTheme, + colors: { + ...MD3LightTheme.colors, + ...LightTheme.colors, + }, +}; +const CombinedDarkTheme = { + ...MD3DarkTheme, + ...DarkTheme, + colors: { + ...MD3DarkTheme.colors, + ...DarkTheme.colors, + }, +}; +``` + ## Passing theme with Providers After combining the themes, we will be able to control theming in both libraries from a single source, which will come in handy later. @@ -128,9 +223,6 @@ After combining the themes, we will be able to control theming in both libraries Next, we need to pass merged themes into the Providers. For this part, we use the dark one - `CombinedDarkTheme`. ```js -const CombinedDefaultTheme = merge(PaperDefaultTheme, NavigationDefaultTheme); -const CombinedDarkTheme = merge(PaperDarkTheme, NavigationDarkTheme); - const Stack = createStackNavigator(); export default function App() { @@ -184,9 +276,6 @@ import { PreferencesContext } from './PreferencesContext'; const Stack = createStackNavigator(); -const CombinedDefaultTheme = merge(PaperDefaultTheme, NavigationDefaultTheme); -const CombinedDarkTheme = merge(PaperDarkTheme, NavigationDarkTheme); - export default function App() { const [isThemeDark, setIsThemeDark] = React.useState(false); @@ -242,7 +331,6 @@ const Header = ({ scene }) => { toggleTheme()}> diff --git a/src/core/__tests__/theming.test.js b/src/core/__tests__/theming.test.js index 60c719a2f8..b4abc9739d 100644 --- a/src/core/__tests__/theming.test.js +++ b/src/core/__tests__/theming.test.js @@ -9,6 +9,7 @@ import { tokens } from '../../styles/themes/v3/tokens'; import { createDynamicThemeColors, getDynamicThemeElevations, + adaptNavigationTheme, } from '../theming'; const sourceColor = 'rgba(200, 100, 0, 1)'; @@ -100,6 +101,44 @@ const dynamicThemeColors = { }, }; +const NavigationLightTheme = { + dark: false, + colors: { + primary: 'rgb(0, 122, 255)', + background: 'rgb(242, 242, 242)', + card: 'rgb(255, 255, 255)', + text: 'rgb(28, 28, 30)', + border: 'rgb(216, 216, 216)', + notification: 'rgb(255, 59, 48)', + }, +}; + +const NavigationDarkTheme = { + dark: true, + colors: { + primary: 'rgb(10, 132, 255)', + background: 'rgb(1, 1, 1)', + card: 'rgb(18, 18, 18)', + text: 'rgb(229, 229, 231)', + border: 'rgb(39, 39, 41)', + notification: 'rgb(255, 69, 58)', + }, +}; + +const NavigationCustomLightTheme = { + dark: false, + colors: { + primary: 'rgb(255, 45, 85)', + secondary: 'rgb(150,45,85)', + tertiary: 'rgb(105,45,85)', + background: 'rgb(242, 242, 242)', + card: 'rgb(255, 255, 255)', + text: 'rgb(28, 28, 30)', + border: 'rgb(199, 199, 204)', + notification: 'rgb(255, 69, 58)', + }, +}; + describe('createDynamicThemeColors', () => { it('should return dark and light theme colors schemes based on source color', () => { const { darkScheme, lightScheme } = createDynamicThemeColors({ @@ -174,3 +213,104 @@ describe('createDynamicThemeColors', () => { }); }); }); + +describe('adaptNavigationTheme', () => { + it('should return adapted both navigation themes', () => { + const themes = adaptNavigationTheme({ + light: NavigationLightTheme, + dark: NavigationDarkTheme, + }); + + expect(themes).toMatchObject({ + LightTheme: { + ...NavigationLightTheme, + colors: { + ...NavigationLightTheme.colors, + primary: MD3LightTheme.colors.primary, + background: MD3LightTheme.colors.background, + card: MD3LightTheme.colors.elevation.level2, + text: MD3LightTheme.colors.onSurface, + border: MD3LightTheme.colors.outline, + notification: MD3LightTheme.colors.error, + }, + }, + DarkTheme: { + ...NavigationDarkTheme, + colors: { + ...NavigationDarkTheme.colors, + primary: MD3DarkTheme.colors.primary, + background: MD3DarkTheme.colors.background, + card: MD3DarkTheme.colors.elevation.level2, + text: MD3DarkTheme.colors.onSurface, + border: MD3DarkTheme.colors.outline, + notification: MD3DarkTheme.colors.error, + }, + }, + }); + }); + + it('should return adapted navigation light theme', () => { + const { LightTheme } = adaptNavigationTheme({ + light: NavigationLightTheme, + }); + + const { colors } = MD3LightTheme; + + expect(LightTheme).toMatchObject({ + ...NavigationLightTheme, + colors: { + ...NavigationLightTheme.colors, + primary: colors.primary, + background: colors.background, + card: colors.elevation.level2, + text: colors.onSurface, + border: colors.outline, + notification: colors.error, + }, + }); + }); + + it('should return adapted navigation dark theme', () => { + const { DarkTheme } = adaptNavigationTheme({ + dark: NavigationDarkTheme, + }); + + const { colors } = MD3DarkTheme; + + expect(DarkTheme).toMatchObject({ + ...NavigationDarkTheme, + colors: { + ...NavigationDarkTheme.colors, + primary: colors.primary, + background: colors.background, + card: colors.elevation.level2, + text: colors.onSurface, + border: colors.outline, + notification: colors.error, + }, + }); + }); + + it('should return adapted custom navigation theme', () => { + const { LightTheme } = adaptNavigationTheme({ + light: NavigationCustomLightTheme, + }); + + const { colors } = MD3LightTheme; + + expect(LightTheme).toMatchObject({ + ...NavigationCustomLightTheme, + colors: { + ...NavigationCustomLightTheme.colors, + primary: colors.primary, + background: colors.background, + card: colors.elevation.level2, + text: colors.onSurface, + border: colors.outline, + notification: colors.error, + secondary: 'rgb(150,45,85)', + tertiary: 'rgb(105,45,85)', + }, + }); + }); +}); diff --git a/src/core/theming.tsx b/src/core/theming.tsx index 9898b6701d..ce6c2bae0b 100644 --- a/src/core/theming.tsx +++ b/src/core/theming.tsx @@ -20,6 +20,7 @@ import type { MD3Theme, MD3Colors, MD3AndroidColors, + NavigationTheme, } from '../types'; export const DefaultTheme = MD3LightTheme; @@ -62,6 +63,84 @@ type Schemes = { darkScheme: MD3Colors; }; +// eslint-disable-next-line no-redeclare +export function adaptNavigationTheme(themes: { light: NavigationTheme }): { + LightTheme: NavigationTheme; +}; +// eslint-disable-next-line no-redeclare +export function adaptNavigationTheme(themes: { dark: NavigationTheme }): { + DarkTheme: NavigationTheme; +}; +// eslint-disable-next-line no-redeclare +export function adaptNavigationTheme(themes: { + light: NavigationTheme; + dark: NavigationTheme; +}): { LightTheme: NavigationTheme; DarkTheme: NavigationTheme }; +// eslint-disable-next-line no-redeclare +export function adaptNavigationTheme(themes: any) { + const { light, dark } = themes; + + const getAdaptedTheme = ( + navigationTheme: NavigationTheme, + MD3Theme: MD3Theme + ) => { + return { + ...navigationTheme, + colors: { + ...navigationTheme.colors, + primary: MD3Theme.colors.primary, + background: MD3Theme.colors.background, + card: MD3Theme.colors.elevation.level2, + text: MD3Theme.colors.onSurface, + border: MD3Theme.colors.outline, + notification: MD3Theme.colors.error, + }, + }; + }; + + if (light && dark) { + const modes = ['light', 'dark'] as const; + + const MD3Themes = { + light: MD3LightTheme, + dark: MD3DarkTheme, + }; + + const NavigationThemes = { + light, + dark, + }; + + const { light: adaptedLight, dark: adaptedDark } = modes.reduce( + (prev, curr) => { + return { + ...prev, + [curr]: getAdaptedTheme(NavigationThemes[curr], MD3Themes[curr]), + }; + }, + { + light, + dark, + } + ); + + return { + LightTheme: adaptedLight, + DarkTheme: adaptedDark, + }; + } + + if (!light) { + return { + DarkTheme: getAdaptedTheme(dark, MD3DarkTheme), + }; + } + + return { + LightTheme: getAdaptedTheme(light, MD3LightTheme), + }; +} + export const getDynamicThemeElevations = (scheme: MD3AndroidColors) => { const elevationValues = ['transparent', 0.05, 0.08, 0.11, 0.12, 0.14]; return elevationValues.reduce((elevations, elevationValue, index) => { diff --git a/src/index.tsx b/src/index.tsx index 54a6a35f8e..aad6a6d74b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,6 +6,7 @@ export { ThemeProvider, DefaultTheme, createDynamicThemeColors, + adaptNavigationTheme, } from './core/theming'; export * from './styles/themes'; diff --git a/src/types.tsx b/src/types.tsx index 3bbb23508d..679a94a8e5 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -195,3 +195,15 @@ export type $RemoveChildren> = $Omit< >; export type EllipsizeProp = 'head' | 'middle' | 'tail' | 'clip'; + +export type NavigationTheme = { + dark: boolean; + colors: { + primary: string; + background: string; + card: string; + text: string; + border: string; + notification: string; + }; +};