diff --git a/example/src/Screens/Layouts.tsx b/example/src/Screens/Layouts.tsx new file mode 100644 index 0000000000..bd10ce5021 --- /dev/null +++ b/example/src/Screens/Layouts.tsx @@ -0,0 +1,150 @@ +import { Button } from '@react-navigation/elements'; +import type { ParamListBase } from '@react-navigation/native'; +import { + createStackNavigator, + type StackNavigationOptions, + type StackScreenProps, +} from '@react-navigation/stack'; +import * as React from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; + +export type SimpleStackParams = { + SuspenseDemo: { author: string } | undefined; +}; + +let cached: number | undefined; + +const createPromise = () => + new Promise((resolve) => { + setTimeout(() => resolve(42), 1000); + }).then((result) => { + cached = result; + return result; + }); + +const SuspenseDemoScreen = ({ + navigation, +}: StackScreenProps) => { + const [promise, setPromise] = React.useState(createPromise); + const [error, setError] = React.useState(null); + + // Naive implementation for suspense intended for demo purposes + // We need to suspend when there's no cached value by throwing a promise + if (cached == null) { + throw promise; + } + + if (error) { + throw error; + } + + return ( + + + + + + + + ); +}; + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + static getDerivedStateFromError() { + return { hasError: true }; + } + + state = { hasError: false }; + + render() { + if (this.state.hasError) { + return ( + + Something went wrong + + + ); + } + + return this.props.children; + } +} + +const Stack = createStackNavigator(); + +export function LayoutsStack({ + navigation, + screenOptions, +}: StackScreenProps & { + screenOptions?: StackNavigationOptions; +}) { + React.useLayoutEffect(() => { + navigation.setOptions({ + headerShown: false, + }); + }, [navigation]); + + return ( + + ( + + + Loading… + + } + > + {children} + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + buttons: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + margin: 12, + }, + fallback: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + + text: { + textAlign: 'center', + margin: 12, + }, +}); diff --git a/example/src/screens.tsx b/example/src/screens.tsx index df5dd0b1c6..43557e9b03 100644 --- a/example/src/screens.tsx +++ b/example/src/screens.tsx @@ -5,6 +5,7 @@ import { BottomTabs } from './Screens/BottomTabs'; import { CustomLayout } from './Screens/CustomLayout'; import { DrawerView } from './Screens/DrawerView'; import { DynamicTabs } from './Screens/DynamicTabs'; +import { LayoutsStack } from './Screens/Layouts'; import { LinkComponent } from './Screens/LinkComponent'; import { LinkingScreen } from './Screens/LinkingScreen'; import { MasterDetail } from './Screens/MasterDetail'; @@ -85,6 +86,10 @@ export const SCREENS = { title: 'Auth Flow', component: AuthFlow, }, + Layouts: { + title: 'Custom Layout', + component: LayoutsStack, + }, StackPreventRemove: { title: 'Prevent removing screen in Stack', component: StackPreventRemove, diff --git a/packages/core/src/__tests__/useDescriptors.test.tsx b/packages/core/src/__tests__/useDescriptors.test.tsx index af158f7e31..0fe44dd39f 100644 --- a/packages/core/src/__tests__/useDescriptors.test.tsx +++ b/packages/core/src/__tests__/useDescriptors.test.tsx @@ -7,6 +7,7 @@ import { act, render } from '@testing-library/react-native'; import * as React from 'react'; import { BaseNavigationContainer } from '../BaseNavigationContainer'; +import { Group } from '../Group'; import { Screen } from '../Screen'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { @@ -383,6 +384,128 @@ it('updates options with setOptions', () => { `); }); +it('renders layout defined for the screen', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder< + NavigationState, + any, + any, + any, + any + >(MockRouter, props); + const { render } = descriptors[state.routes[state.index].key]; + + return render(); + }; + + const TestScreen = () => { + return <>Test screen; + }; + + const element = ( + +
{children}
} + > +
{children}
}> +
{children}
} + /> + +
+
+
+ ); + + const root = render(element); + + expect(root).toMatchInlineSnapshot(` +
+ Test screen +
+`); +}); + +it('renders layout defined for the group', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder< + NavigationState, + any, + any, + any, + any + >(MockRouter, props); + const { render } = descriptors[state.routes[state.index].key]; + + return render(); + }; + + const TestScreen = () => { + return <>Test screen; + }; + + const element = ( + +
{children}
} + > +
{children}
}> + + +
+
+
+ ); + + const root = render(element); + + expect(root).toMatchInlineSnapshot(` +
+ Test screen +
+`); +}); + +it('renders layout defined for the navigator', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder< + NavigationState, + any, + any, + any, + any + >(MockRouter, props); + const { render } = descriptors[state.routes[state.index].key]; + + return render(); + }; + + const TestScreen = () => { + return <>Test screen; + }; + + const element = ( + +
{children}
} + > + + +
+
+ ); + + const root = render(element); + + expect(root).toMatchInlineSnapshot(` +
+ Test screen +
+`); +}); + it("returns correct value for canGoBack when it's not overridden", () => { const TestNavigator = (props: any) => { const { state, descriptors } = useNavigationBuilder< diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index e575360d74..cf286e4682 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -32,13 +32,15 @@ export type DefaultNavigatorOptions< * Optional ID for the navigator. Can be used with `navigation.getParent(id)` to refer to a parent. */ id?: string; + /** * Children React Elements to extract the route configuration from. * Only `Screen`, `Group` and `React.Fragment` are supported as children. */ children: React.ReactNode; + /** - * Layout component for the navigator. + * Layout for the navigator. * Useful for wrapping with a component with access to navigator's state and options. */ layout?: (props: { @@ -61,6 +63,7 @@ export type DefaultNavigatorOptions< >; children: React.ReactNode; }) => React.ReactElement; + /** * Event listeners for all the screens in the navigator. */ @@ -70,6 +73,7 @@ export type DefaultNavigatorOptions< route: RouteProp; navigation: any; }) => ScreenListeners); + /** * Default options for all screens under this navigator. */ @@ -80,6 +84,17 @@ export type DefaultNavigatorOptions< navigation: any; theme: ReactNavigation.Theme; }) => ScreenOptions); + + /** + * Layout for all screens under this navigator. + */ + screenLayout?: (props: { + route: RouteProp; + navigation: any; + theme: ReactNavigation.Theme; + children: React.ReactElement; + }) => React.ReactElement; + /** A function returning a state, which may be set after modifying the routes name. */ @@ -643,6 +658,18 @@ export type RouteConfig< navigation: any; }) => ScreenListeners); + /** + * Layout for this screen. + * Useful for wrapping the screen with custom containers. + * e.g. for styling, error boundaries, suspense, etc. + */ + layout?: (props: { + route: RouteProp; + navigation: any; + theme: ReactNavigation.Theme; + children: React.ReactElement; + }) => React.ReactElement; + /** * Function to return an unique ID for this screen. * Receives an object with the route params. @@ -681,6 +708,18 @@ export type RouteGroupConfig< navigation: any; theme: ReactNavigation.Theme; }) => ScreenOptions); + + /** + * Layout for the screens inside the group. + * This will override the `screenLayout` of parent group or navigator. + */ + screenLayout?: (props: { + route: RouteProp; + navigation: any; + theme: ReactNavigation.Theme; + children: React.ReactElement; + }) => React.ReactElement; + /** * Children React Elements to extract the route configuration from. * Only `Screen`, `Group` and `React.Fragment` are supported as children. diff --git a/packages/core/src/useDescriptors.tsx b/packages/core/src/useDescriptors.tsx index 01767bd494..f981931bcb 100644 --- a/packages/core/src/useDescriptors.tsx +++ b/packages/core/src/useDescriptors.tsx @@ -35,9 +35,17 @@ export type ScreenConfigWithParent< > = { keys: (string | undefined)[]; options: (ScreenOptionsOrCallback | undefined)[] | undefined; + layout: ScreenLayout | undefined; props: RouteConfig; }; +type ScreenLayout = (props: { + route: RouteProp; + navigation: any; + theme: ReactNavigation.Theme; + children: React.ReactElement; +}) => React.ReactElement; + type ScreenOptionsOrCallback = | ScreenOptions | ((props: { @@ -57,7 +65,8 @@ type Options< ScreenConfigWithParent >; navigation: NavigationHelpers; - screenOptions?: ScreenOptionsOrCallback; + screenOptions: ScreenOptionsOrCallback | undefined; + screenLayout: ScreenLayout | undefined; onAction: (action: NavigationAction) => boolean; getState: () => State; setState: (state: State) => void; @@ -86,6 +95,7 @@ export function useDescriptors< screens, navigation, screenOptions, + screenLayout, onAction, getState, setState, @@ -207,20 +217,42 @@ export function useDescriptors< return o; }); + const layout = + // The `layout` prop passed to `Screen` elements, + screen.layout ?? + // The `screenLayout` props passed to `Group` elements + config.layout ?? + // The default `screenLayout` passed to the navigator + screenLayout; + + let element = ( + + ); + + if (layout != null) { + element = layout({ + route, + navigation, + // @ts-expect-error: in practice `theme` will be defined + theme, + children: element, + }); + } + return ( - + {element} diff --git a/packages/core/src/useNavigationBuilder.tsx b/packages/core/src/useNavigationBuilder.tsx index 2635efbde0..d21ecada02 100644 --- a/packages/core/src/useNavigationBuilder.tsx +++ b/packages/core/src/useNavigationBuilder.tsx @@ -72,7 +72,8 @@ const getRouteConfigsFromChildren = < State, ScreenOptions, EventMap - >['options'] + >['options'], + groupLayout?: ScreenConfigWithParent['layout'] ) => { const configs = React.Children.toArray(children).reduce< ScreenConfigWithParent[] @@ -95,6 +96,7 @@ const getRouteConfigsFromChildren = < acc.push({ keys: [groupKey, child.props.navigationKey], options: groupOptions, + layout: groupLayout, props: child.props as RouteConfig< ParamListBase, string, @@ -103,6 +105,7 @@ const getRouteConfigsFromChildren = < EventMap >, }); + return acc; } @@ -125,7 +128,8 @@ const getRouteConfigsFromChildren = < ? groupOptions : groupOptions != null ? [...groupOptions, child.props.screenOptions] - : [child.props.screenOptions] + : [child.props.screenOptions], + child.props.screenLayout ?? groupLayout ) ); return acc; @@ -259,7 +263,15 @@ export function useNavigationBuilder< | NavigatorRoute | undefined; - const { children, layout, screenOptions, screenListeners, ...rest } = options; + const { + children, + layout, + screenOptions, + screenLayout, + screenListeners, + ...rest + } = options; + const { current: router } = React.useRef>( createRouter(rest as unknown as RouterOptions) ); @@ -689,6 +701,7 @@ export function useNavigationBuilder< screens, navigation, screenOptions, + screenLayout, onAction, getState, setState, diff --git a/packages/drawer/src/navigators/createDrawerNavigator.tsx b/packages/drawer/src/navigators/createDrawerNavigator.tsx index 55ed89c3e8..7295cb6ece 100644 --- a/packages/drawer/src/navigators/createDrawerNavigator.tsx +++ b/packages/drawer/src/navigators/createDrawerNavigator.tsx @@ -35,6 +35,7 @@ function DrawerNavigator({ layout, screenListeners, screenOptions, + screenLayout, ...rest }: Props) { const { state, descriptors, navigation, NavigationContent } = @@ -53,6 +54,7 @@ function DrawerNavigator({ layout, screenListeners, screenOptions, + screenLayout, }); return ( diff --git a/packages/material-top-tabs/src/navigators/createMaterialTopTabNavigator.tsx b/packages/material-top-tabs/src/navigators/createMaterialTopTabNavigator.tsx index 5725ff2337..0be5b5ed1c 100644 --- a/packages/material-top-tabs/src/navigators/createMaterialTopTabNavigator.tsx +++ b/packages/material-top-tabs/src/navigators/createMaterialTopTabNavigator.tsx @@ -34,6 +34,7 @@ function MaterialTopTabNavigator({ layout, screenListeners, screenOptions, + screenLayout, ...rest }: Props) { const { state, descriptors, navigation, NavigationContent } = @@ -51,6 +52,7 @@ function MaterialTopTabNavigator({ layout, screenListeners, screenOptions, + screenLayout, }); return ( diff --git a/packages/native-stack/src/navigators/createNativeStackNavigator.tsx b/packages/native-stack/src/navigators/createNativeStackNavigator.tsx index c85444fc0c..06991f5fb6 100644 --- a/packages/native-stack/src/navigators/createNativeStackNavigator.tsx +++ b/packages/native-stack/src/navigators/createNativeStackNavigator.tsx @@ -25,6 +25,7 @@ function NativeStackNavigator({ layout, screenListeners, screenOptions, + screenLayout, ...rest }: NativeStackNavigatorProps) { const { state, descriptors, navigation, NavigationContent } = @@ -41,6 +42,7 @@ function NativeStackNavigator({ layout, screenListeners, screenOptions, + screenLayout, }); React.useEffect( diff --git a/packages/stack/src/navigators/createStackNavigator.tsx b/packages/stack/src/navigators/createStackNavigator.tsx index 781230ca21..4082625133 100644 --- a/packages/stack/src/navigators/createStackNavigator.tsx +++ b/packages/stack/src/navigators/createStackNavigator.tsx @@ -37,6 +37,7 @@ function StackNavigator({ layout, screenListeners, screenOptions, + screenLayout, ...rest }: Props) { const { direction } = useLocale(); @@ -55,6 +56,7 @@ function StackNavigator({ layout, screenListeners, screenOptions, + screenLayout, getStateForRouteNamesChange, });