Skip to content

Commit

Permalink
feat: move theming to core and pass theme to options (#11707)
Browse files Browse the repository at this point in the history
This moves theming logic to `@react-navigation/core`. So we can now pass the `theme` to the `options` and `screenOptions` callbacks. As `options`. Often specifies UI elements and styles, it's nice to have theme available there.
  • Loading branch information
satya164 committed Nov 20, 2023
1 parent d718b71 commit 8e7ac4f
Show file tree
Hide file tree
Showing 15 changed files with 223 additions and 53 deletions.
11 changes: 5 additions & 6 deletions example/src/Screens/StackHeaderCustomization.tsx
Expand Up @@ -4,7 +4,7 @@ import {
HeaderBackground,
useHeaderHeight,
} from '@react-navigation/elements';
import { type ParamListBase, useTheme } from '@react-navigation/native';
import { type ParamListBase } from '@react-navigation/native';
import {
createStackNavigator,
Header,
Expand Down Expand Up @@ -106,7 +106,6 @@ export function StackHeaderCustomization({ navigation }: Props) {
});
}, [navigation]);

const { colors, dark } = useTheme();
const [headerTitleCentered, setHeaderTitleCentered] = React.useState(true);

return (
Expand Down Expand Up @@ -148,7 +147,7 @@ export function StackHeaderCustomization({ navigation }: Props) {
<Stack.Screen
name="Albums"
component={AlbumsScreen}
options={{
options={({ theme }) => ({
title: 'Albums',
headerBackTitle: 'Back',
headerTransparent: true,
Expand All @@ -157,17 +156,17 @@ export function StackHeaderCustomization({ navigation }: Props) {
style={{
backgroundColor: 'blue',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
borderBottomColor: theme.colors.border,
}}
>
<BlurView
tint={dark ? 'dark' : 'light'}
tint={theme.dark ? 'dark' : 'light'}
intensity={75}
style={StyleSheet.absoluteFill}
/>
</HeaderBackground>
),
}}
})}
/>
</Stack.Navigator>
);
Expand Down
3 changes: 3 additions & 0 deletions example/src/Screens/Static.tsx
Expand Up @@ -48,6 +48,9 @@ const AlbumsScreen = () => {
};

const HomeTabs = createBottomTabNavigator({
screenOptions: ({ theme }) => ({
tabBarActiveTintColor: theme.colors.notification,
}),
screens: {
Albums: {
screen: AlbumsScreen,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/BaseNavigationContainer.tsx
Expand Up @@ -20,6 +20,7 @@ import { NavigationBuilderContext } from './NavigationBuilderContext';
import { NavigationContainerRefContext } from './NavigationContainerRefContext';
import { NavigationIndependentTreeContext } from './NavigationIndependentTreeContext';
import { NavigationStateContext } from './NavigationStateContext';
import { ThemeProvider } from './theming/ThemeProvider';
import type {
NavigationContainerEventMap,
NavigationContainerProps,
Expand Down Expand Up @@ -76,6 +77,7 @@ const getPartialState = (
* @param props.onReady Callback which is called after the navigation tree mounts.
* @param props.onStateChange Callback which is called with the latest navigation state when it changes.
* @param props.onUnhandledAction Callback which is called when an action is not handled.
* @param props.theme Theme object for the UI elements.
* @param props.children Child elements to render the content.
* @param props.ref Ref object which refers to the navigation object containing helper methods.
*/
Expand All @@ -87,6 +89,7 @@ export const BaseNavigationContainer = React.forwardRef(
onReady,
onUnhandledAction,
navigationInChildEnabled = false,
theme,
children,
}: NavigationContainerProps,
ref?: React.Ref<NavigationContainerRef<ParamListBase>>
Expand Down Expand Up @@ -434,7 +437,9 @@ export const BaseNavigationContainer = React.forwardRef(
<DeprecatedNavigationInChildContext.Provider
value={navigationInChildEnabled}
>
<EnsureSingleNavigator>{children}</EnsureSingleNavigator>
<EnsureSingleNavigator>
<ThemeProvider value={theme}>{children}</ThemeProvider>
</EnsureSingleNavigator>
</DeprecatedNavigationInChildContext.Provider>
</UnhandledActionContext.Provider>
</NavigationStateContext.Provider>
Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/__tests__/theming.test.tsx
@@ -0,0 +1,142 @@
import { render } from '@testing-library/react-native';
import * as React from 'react';

import { BaseNavigationContainer } from '../BaseNavigationContainer';
import { Screen } from '../Screen';
import { useTheme } from '../theming/useTheme';
import { useNavigationBuilder } from '../useNavigationBuilder';
import { MockRouter } from './__fixtures__/MockRouter';

it('can get current theme with useTheme', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);

return state.routes.map((route) => descriptors[route.key].render());
};

const Test = () => {
const theme = useTheme();

expect(theme).toEqual({
colors: {
primary: 'tomato',
},
});

return null;
};

// Incomplete theme for testing
const theme: any = {
colors: {
primary: 'tomato',
},
};

render(
<BaseNavigationContainer theme={theme}>
<TestNavigator>
<Screen name="foo" component={Test} />
</TestNavigator>
</BaseNavigationContainer>
);
});

it("throws if theme isn't passed to BaseNavigationContainer", () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);

return state.routes.map((route) => descriptors[route.key].render());
};

const Test = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
expect(() => useTheme()).toThrow("Couldn't find a theme");

return null;
};

render(
<BaseNavigationContainer>
<TestNavigator>
<Screen name="foo" component={Test} />
</TestNavigator>
</BaseNavigationContainer>
);
});

it('throws if useTheme is used without BaseNavigationContainer', () => {
const Test = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
expect(() => useTheme()).toThrow("Couldn't find a theme");

return null;
};

render(<Test />);
});

it('passes theme to options prop', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);

expect(descriptors[state.routes[0].key].options).toEqual({
title: 'tomato',
});

return null;
};

// Incomplete theme for testing
const theme: any = {
colors: {
primary: 'tomato',
},
};

render(
<BaseNavigationContainer theme={theme}>
<TestNavigator>
<Screen
name="foo"
component={React.Fragment}
options={({ theme }: any) => ({ title: theme.colors.primary })}
/>
</TestNavigator>
</BaseNavigationContainer>
);
});

it('passes theme to screenOptions prop', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);

expect(descriptors[state.routes[0].key].options).toEqual({
title: 'tomato',
});

expect(descriptors[state.routes[1].key].options).toEqual({
title: 'tomato',
});

return null;
};

// Incomplete theme for testing
const theme: any = {
colors: {
primary: 'tomato',
},
};

render(
<BaseNavigationContainer theme={theme}>
<TestNavigator
screenOptions={({ theme }: any) => ({ title: theme.colors.primary })}
>
<Screen name="foo" component={React.Fragment} />
<Screen name="bar" component={React.Fragment} />
</TestNavigator>
</BaseNavigationContainer>
);
});
3 changes: 3 additions & 0 deletions packages/core/src/index.tsx
Expand Up @@ -21,6 +21,9 @@ export {
type StaticParamList,
type StaticScreenProps,
} from './StaticNavigation';
export { ThemeContext } from './theming/ThemeContext';
export { ThemeProvider } from './theming/ThemeProvider';
export { useTheme } from './theming/useTheme';
export * from './types';
export { useFocusEffect } from './useFocusEffect';
export { useIsFocused } from './useIsFocused';
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/theming/ThemeContext.tsx
@@ -0,0 +1,7 @@
import * as React from 'react';

export const ThemeContext = React.createContext<
ReactNavigation.Theme | undefined
>(undefined);

ThemeContext.displayName = 'ThemeContext';
@@ -1,10 +1,9 @@
import * as React from 'react';

import type { Theme } from '../types';
import { ThemeContext } from './ThemeContext';

type Props = {
value: Theme;
value: ReactNavigation.Theme | undefined;
children: React.ReactNode;
};

Expand Down
Expand Up @@ -5,5 +5,11 @@ import { ThemeContext } from './ThemeContext';
export function useTheme() {
const theme = React.useContext(ThemeContext);

if (theme == null) {
throw new Error(
"Couldn't find a theme. Is your component inside NavigationContainer or does it have a theme?"
);
}

return theme;
}
10 changes: 10 additions & 0 deletions packages/core/src/types.tsx
Expand Up @@ -14,6 +14,9 @@ declare global {
namespace ReactNavigation {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface RootParamList {}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Theme {}
}
}

Expand Down Expand Up @@ -75,6 +78,7 @@ export type DefaultNavigatorOptions<
| ((props: {
route: RouteProp<ParamList>;
navigation: any;
theme: ReactNavigation.Theme;
}) => ScreenOptions);
/**
A function returning a state, which may be set after modifying the routes name.
Expand Down Expand Up @@ -379,6 +383,10 @@ export type NavigationContainerProps = {
* @deprecated Use nested navigation API instead
*/
navigationInChildEnabled?: boolean;
/**
* Theme object for the UI elements.
*/
theme?: ReactNavigation.Theme;
/**
* Children elements to render.
*/
Expand Down Expand Up @@ -590,6 +598,7 @@ export type RouteConfig<
| ((props: {
route: RouteProp<ParamList, RouteName>;
navigation: any;
theme: ReactNavigation.Theme;
}) => ScreenOptions);

/**
Expand Down Expand Up @@ -638,6 +647,7 @@ export type RouteGroupConfig<
| ((props: {
route: RouteProp<ParamList, keyof ParamList>;
navigation: any;
theme: ReactNavigation.Theme;
}) => ScreenOptions);
/**
* Children React Elements to extract the route configuration from.
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/useDescriptors.tsx
Expand Up @@ -14,6 +14,7 @@ import {
import { NavigationContext } from './NavigationContext';
import { NavigationRouteContext } from './NavigationRouteContext';
import { SceneView } from './SceneView';
import { ThemeContext } from './theming/ThemeContext';
import type {
Descriptor,
EventMapBase,
Expand Down Expand Up @@ -41,6 +42,7 @@ type ScreenOptionsOrCallback<ScreenOptions extends {}> =
| ((props: {
route: RouteProp<ParamListBase, string>;
navigation: any;
theme: ReactNavigation.Theme;
}) => ScreenOptions);

type Options<
Expand Down Expand Up @@ -92,6 +94,7 @@ export function useDescriptors<
router,
emitter,
}: Options<State, ScreenOptions, EventMap>) {
const theme = React.useContext(ThemeContext);
const [options, setOptions] = React.useState<Record<string, ScreenOptions>>(
{}
);
Expand Down Expand Up @@ -173,7 +176,7 @@ export function useDescriptors<
Object.assign(
acc,
// @ts-expect-error: we check for function but TS still complains
typeof curr !== 'function' ? curr : curr({ route, navigation })
typeof curr !== 'function' ? curr : curr({ route, navigation, theme })
),
{} as ScreenOptions
);
Expand Down
2 changes: 1 addition & 1 deletion packages/native/src/Link.tsx
@@ -1,3 +1,4 @@
import { useTheme } from '@react-navigation/core';
import * as React from 'react';
import {
type GestureResponderEvent,
Expand All @@ -6,7 +7,6 @@ import {
type TextProps,
} from 'react-native';

import { useTheme } from './theming/useTheme';
import { type Props as LinkProps, useLinkProps } from './useLinkProps';

type Props<ParamList extends ReactNavigation.RootParamList> =
Expand Down

0 comments on commit 8e7ac4f

Please sign in to comment.