feat: add a new component to group multiple screens with common options

This commit is contained in:
Satyajit Sahoo
2021-05-09 00:16:27 +02:00
parent 33b2dbb85c
commit 1a6aebefcb
8 changed files with 443 additions and 126 deletions

View File

@@ -117,24 +117,29 @@ export default function SimpleStackScreen({
return (
<SimpleStack.Navigator
screenOptions={{
...TransitionPresets.SlideFromRightIOS,
headerMode: 'float',
headerStyleInterpolator: HeaderStyleInterpolators.forUIKit,
}}
>
<SimpleStack.Screen
name="Article"
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params?.author ?? 'Unknown'}`,
})}
initialParams={{ author: 'Gandalf' }}
/>
<SimpleStack.Screen
name="NewsFeed"
component={NewsFeedScreen}
options={{ title: 'Feed' }}
/>
<SimpleStack.Group
screenOptions={{
...TransitionPresets.SlideFromRightIOS,
headerMode: 'float',
}}
>
<SimpleStack.Screen
name="Article"
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params?.author ?? 'Unknown'}`,
})}
initialParams={{ author: 'Gandalf' }}
/>
<SimpleStack.Screen
name="NewsFeed"
component={NewsFeedScreen}
options={{ title: 'Feed' }}
/>
</SimpleStack.Group>
<SimpleStack.Screen
name="Albums"
component={AlbumsScreen}

View File

@@ -0,0 +1,13 @@
import type { ParamListBase } from '@react-navigation/routers';
import type { RouteGroupConfig } from './types';
/**
* Empty component used for grouping screen configs.
*/
export default function Group<
ParamList extends ParamListBase,
ScreenOptions extends {}
>(_: RouteGroupConfig<ParamList, ScreenOptions>) {
/* istanbul ignore next */
return null;
}

View File

@@ -9,14 +9,10 @@ import NavigationStateContext from './NavigationStateContext';
import StaticContainer from './StaticContainer';
import EnsureSingleNavigator from './EnsureSingleNavigator';
import useOptionsGetters from './useOptionsGetters';
import type { NavigationProp, RouteConfig, EventMapBase } from './types';
import type { NavigationProp, RouteConfigComponent } from './types';
type Props<
State extends NavigationState,
ScreenOptions extends {},
EventMap extends EventMapBase
> = {
screen: RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>;
type Props<State extends NavigationState, ScreenOptions extends {}> = {
screen: RouteConfigComponent<ParamListBase, string> & { name: string };
navigation: NavigationProp<ParamListBase, string, State, ScreenOptions>;
route: Route<string>;
routeState: NavigationState | PartialState<NavigationState> | undefined;
@@ -31,8 +27,7 @@ type Props<
*/
export default function SceneView<
State extends NavigationState,
ScreenOptions extends {},
EventMap extends EventMapBase
ScreenOptions extends {}
>({
screen,
route,
@@ -41,7 +36,7 @@ export default function SceneView<
getState,
setState,
options,
}: Props<State, ScreenOptions, EventMap>) {
}: Props<State, ScreenOptions>) {
const navigatorKeyRef = React.useRef<string | undefined>();
const getKey = React.useCallback(() => navigatorKeyRef.current, []);

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { render, act } from '@testing-library/react-native';
import type { NavigationState, ParamListBase } from '@react-navigation/routers';
import Group from '../Group';
import Screen from '../Screen';
import BaseNavigationContainer from '../BaseNavigationContainer';
import useNavigationBuilder from '../useNavigationBuilder';
@@ -248,6 +249,53 @@ it('initializes state for nested screens in React.Fragment', () => {
});
});
it('initializes state for nested screens in Group', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const TestScreen = (props: any) => {
React.useEffect(() => {
props.navigation.dispatch({ type: 'UPDATE' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
};
const onStateChange = jest.fn();
const element = (
<BaseNavigationContainer onStateChange={onStateChange}>
<TestNavigator>
<Screen name="foo" component={TestScreen} />
<Group>
<Screen name="bar" component={jest.fn()} />
<Screen name="baz" component={jest.fn()} />
</Group>
</TestNavigator>
</BaseNavigationContainer>
);
render(element).update(element);
expect(onStateChange).toBeCalledTimes(1);
expect(onStateChange).toBeCalledWith({
stale: false,
type: 'test',
index: 0,
key: '0',
routeNames: ['foo', 'bar', 'baz'],
routes: [
{ key: 'foo', name: 'foo' },
{ key: 'bar', name: 'bar' },
{ key: 'baz', name: 'baz' },
],
});
});
it('initializes state for nested navigator on navigation', () => {
const TestNavigator = (props: any) => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
@@ -1450,7 +1498,7 @@ it('throws when Screen is not the direct children', () => {
);
expect(() => render(element).update(element)).toThrowError(
"A navigator can only contain 'Screen' components as its direct children (found 'Bar')"
"A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'Bar')"
);
});
@@ -1475,7 +1523,7 @@ it('throws when undefined component is a direct children', () => {
spy.mockRestore();
expect(() => render(element).update(element)).toThrowError(
"A navigator can only contain 'Screen' components as its direct children (found 'undefined' for the screen 'foo')"
"A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'undefined' for the screen 'foo')"
);
});
@@ -1495,7 +1543,7 @@ it('throws when a tag is a direct children', () => {
);
expect(() => render(element).update(element)).toThrowError(
"A navigator can only contain 'Screen' components as its direct children (found 'screen' for the screen 'foo')"
"A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'screen' for the screen 'foo')"
);
});
@@ -1515,7 +1563,7 @@ it('throws when a React Element is not the direct children', () => {
);
expect(() => render(element).update(element)).toThrowError(
"A navigator can only contain 'Screen' components as its direct children (found 'Hello world')"
"A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'Hello world')"
);
});
@@ -1838,19 +1886,16 @@ it("returns focused screen's options with getCurrentOptions when focused screen
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: 'data' })}
>
<TestNavigator initialRouteName="bar-a">
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: 'data' }}
options={{ sample: '1' }}
/>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: 'data' }}
options={{ sample2: '2' }}
/>
</TestNavigator>
)}
@@ -1863,15 +1908,122 @@ it("returns focused screen's options with getCurrentOptions when focused screen
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: 'data',
sample2: 'data',
sample: '1',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: 'data',
sample3: 'data',
sample2: '2',
});
});
it("returns focused screen's options with getCurrentOptions when focused screen is rendered when using screenOptions", () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const TestScreen = () => null;
const navigation = createNavigationContainerRef<ParamListBase>();
const container = (
<BaseNavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: '2' })}
>
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: '1' }}
/>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: '3' }}
/>
</TestNavigator>
)}
</Screen>
<Screen name="xux" component={TestScreen} />
</TestNavigator>
</BaseNavigationContainer>
);
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: '1',
sample2: '2',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: '2',
sample3: '3',
});
});
it("returns focused screen's options with getCurrentOptions when focused screen is rendered when using Group", () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const TestScreen = () => null;
const navigation = createNavigationContainerRef<ParamListBase>();
const container = (
<BaseNavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: '2' })}
>
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: '1' }}
/>
<Group screenOptions={{ sample4: '4' }}>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: '3' }}
/>
</Group>
</TestNavigator>
)}
</Screen>
<Screen name="xux" component={TestScreen} />
</TestNavigator>
</BaseNavigationContainer>
);
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: '1',
sample2: '2',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: '2',
sample3: '3',
sample4: '4',
});
});
@@ -1891,19 +2043,16 @@ it("returns focused screen's options with getCurrentOptions when all screens are
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: 'data' })}
>
<TestNavigator initialRouteName="bar-a">
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: 'data' }}
options={{ sample: '1' }}
/>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: 'data' }}
options={{ sample2: '2' }}
/>
</TestNavigator>
)}
@@ -1916,15 +2065,122 @@ it("returns focused screen's options with getCurrentOptions when all screens are
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: 'data',
sample2: 'data',
sample: '1',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: 'data',
sample3: 'data',
sample2: '2',
});
});
it("returns focused screen's options with getCurrentOptions when all screens are rendered with screenOptions", () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return <>{state.routes.map((route) => descriptors[route.key].render())}</>;
};
const TestScreen = () => null;
const navigation = createNavigationContainerRef<ParamListBase>();
const container = (
<BaseNavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: '2' })}
>
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: '1' }}
/>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: '3' }}
/>
</TestNavigator>
)}
</Screen>
<Screen name="xux" component={TestScreen} />
</TestNavigator>
</BaseNavigationContainer>
);
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: '1',
sample2: '2',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: '2',
sample3: '3',
});
});
it("returns focused screen's options with getCurrentOptions when all screens are rendered with Group", () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return <>{state.routes.map((route) => descriptors[route.key].render())}</>;
};
const TestScreen = () => null;
const navigation = createNavigationContainerRef<ParamListBase>();
const container = (
<BaseNavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="bar" options={{ a: 'b' }}>
{() => (
<TestNavigator
initialRouteName="bar-a"
screenOptions={() => ({ sample2: '2' })}
>
<Screen
name="bar-a"
component={TestScreen}
options={{ sample: '1' }}
/>
<Group screenOptions={{ sample4: '4' }}>
<Screen
name="bar-b"
component={TestScreen}
options={{ sample3: '3' }}
/>
</Group>
</TestNavigator>
)}
</Screen>
<Screen name="xux" component={TestScreen} />
</TestNavigator>
</BaseNavigationContainer>
);
render(container).update(container);
expect(navigation.getCurrentOptions()).toEqual({
sample: '1',
sample2: '2',
});
act(() => navigation.navigate('bar-b'));
expect(navigation.getCurrentOptions()).toEqual({
sample2: '2',
sample3: '3',
sample4: '4',
});
});

View File

@@ -1,5 +1,6 @@
import type * as React from 'react';
import type { ParamListBase, NavigationState } from '@react-navigation/routers';
import Group from './Group';
import Screen from './Screen';
import type { TypedNavigator, EventMapBase } from './types';
@@ -31,6 +32,7 @@ export default function createNavigatorFactory<
return {
Navigator,
Group,
Screen,
};
};

View File

@@ -17,7 +17,7 @@ export type DefaultNavigatorOptions<
> = DefaultRouterOptions<Keyof<ParamList>> & {
/**
* Children React Elements to extract the route configuration from.
* Only `Screen` components are supported as children.
* Only `Screen`, `Group` and `React.Fragment` are supported as children.
*/
children: React.ReactNode;
/**
@@ -376,6 +376,38 @@ export type ScreenListeners<
}
>;
export type RouteConfigComponent<
ParamList extends ParamListBase,
RouteName extends keyof ParamList
> =
| {
/**
* React component to render for this screen.
*/
component: React.ComponentType<any>;
getComponent?: never;
children?: never;
}
| {
/**
* Lazily get a React component to render for this screen.
*/
getComponent: () => React.ComponentType<any>;
component?: never;
children?: never;
}
| {
/**
* Render callback to render content of this screen.
*/
children: (props: {
route: RouteProp<ParamList, RouteName>;
navigation: any;
}) => React.ReactNode;
component?: never;
getComponent?: never;
};
export type RouteConfig<
ParamList extends ParamListBase,
RouteName extends keyof ParamList,
@@ -420,35 +452,27 @@ export type RouteConfig<
* Initial params object for the route.
*/
initialParams?: Partial<ParamList[RouteName]>;
} & (
| {
/**
* React component to render for this screen.
*/
component: React.ComponentType<any>;
getComponent?: never;
children?: never;
}
| {
/**
* Lazily get a React component to render for this screen.
*/
getComponent: () => React.ComponentType<any>;
component?: never;
children?: never;
}
| {
/**
* Render callback to render content of this screen.
*/
children: (props: {
route: RouteProp<ParamList, RouteName>;
} & RouteConfigComponent<ParamList, RouteName>;
export type RouteGroupConfig<
ParamList extends ParamListBase,
ScreenOptions extends {}
> = {
/**
* Navigator options for this screen.
*/
screenOptions?:
| ScreenOptions
| ((props: {
route: RouteProp<ParamList, keyof ParamList>;
navigation: any;
}) => React.ReactNode;
component?: never;
getComponent?: never;
}
);
}) => ScreenOptions);
/**
* Children React Elements to extract the route configuration from.
* Only `Screen`, `Group` and `React.Fragment` are supported as children.
*/
children: React.ReactNode;
};
export type NavigationContainerEventMap = {
/**
@@ -536,6 +560,10 @@ export type TypedNavigator<
> &
DefaultNavigatorOptions<ScreenOptions, ParamList>
>;
/**
* Component used for grouping multiple route configuration.
*/
Group: React.ComponentType<RouteGroupConfig<ParamList, ScreenOptions>>;
/**
* Component used for specifying route configuration.
*/

View File

@@ -24,6 +24,22 @@ import type {
NavigationProp,
} from './types';
export type ScreenConfigWithParent<
State extends NavigationState,
ScreenOptions extends {},
EventMap extends EventMapBase
> = [
(ScreenOptionsOrCallback<ScreenOptions> | undefined)[] | undefined,
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>
];
type ScreenOptionsOrCallback<ScreenOptions extends {}> =
| ScreenOptions
| ((props: {
route: RouteProp<ParamListBase, string>;
navigation: any;
}) => ScreenOptions);
type Options<
State extends NavigationState,
ScreenOptions extends {},
@@ -32,15 +48,10 @@ type Options<
state: State;
screens: Record<
string,
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>
ScreenConfigWithParent<State, ScreenOptions, EventMap>
>;
navigation: NavigationHelpers<ParamListBase>;
screenOptions?:
| ScreenOptions
| ((props: {
route: RouteProp<ParamListBase>;
navigation: any;
}) => ScreenOptions);
screenOptions?: ScreenOptionsOrCallback<ScreenOptions>;
defaultScreenOptions?:
| ScreenOptions
| ((props: {
@@ -137,29 +148,31 @@ export default function useDescriptors<
>
>
>((acc, route, i) => {
const screen = screens[route.name];
const config = screens[route.name];
const screen = config[1];
const navigation = navigations[route.key];
const customOptions = {
const optionsList = [
// The default `screenOptions` passed to the navigator
...(typeof screenOptions === 'object' || screenOptions == null
? screenOptions
: // @ts-expect-error: this is a function, but typescript doesn't think so
screenOptions({
route,
navigation,
})),
// The `options` prop passed to `Screen` elements
...(typeof screen.options === 'object' || screen.options == null
? screen.options
: // @ts-expect-error: this is a function, but typescript doesn't think so
screen.options({
route,
navigation,
})),
screenOptions,
// The `screenOptions` props passed to `Group` elements
...((config[0]
? config[0].filter(Boolean)
: []) as ScreenOptionsOrCallback<ScreenOptions>[]),
// The `options` prop passed to `Screen` elements,
screen.options,
// The options set via `navigation.setOptions`
...options[route.key],
};
options[route.key],
];
const customOptions = optionsList.reduce<ScreenOptions>(
(acc, curr) =>
Object.assign(
acc,
typeof curr !== 'function' ? curr : curr({ route, navigation })
),
{} as ScreenOptions
);
const mergedOptions = {
...(typeof defaultScreenOptions === 'function'

View File

@@ -14,10 +14,11 @@ import {
} from '@react-navigation/routers';
import NavigationStateContext from './NavigationStateContext';
import NavigationRouteContext from './NavigationRouteContext';
import Group from './Group';
import Screen from './Screen';
import useEventEmitter from './useEventEmitter';
import useRegisterNavigator from './useRegisterNavigator';
import useDescriptors from './useDescriptors';
import useDescriptors, { ScreenConfigWithParent } from './useDescriptors';
import useNavigationHelpers from './useNavigationHelpers';
import useOnAction from './useOnAction';
import useFocusEvents from './useFocusEvents';
@@ -57,33 +58,40 @@ const getRouteConfigsFromChildren = <
ScreenOptions extends {},
EventMap extends EventMapBase
>(
children: React.ReactNode
children: React.ReactNode,
options?: ScreenConfigWithParent<State, ScreenOptions, EventMap>[0]
) => {
const configs = React.Children.toArray(children).reduce<
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>[]
ScreenConfigWithParent<State, ScreenOptions, EventMap>[]
>((acc, child) => {
if (React.isValidElement(child)) {
if (child.type === Screen) {
// We can only extract the config from `Screen` elements
// If something else was rendered, it's probably a bug
acc.push(
acc.push([
options,
child.props as RouteConfig<
ParamListBase,
string,
State,
ScreenOptions,
EventMap
>
);
>,
]);
return acc;
}
if (child.type === React.Fragment) {
// When we encounter a fragment, we need to dive into its children to extract the configs
if (child.type === React.Fragment || child.type === Group) {
// When we encounter a fragment or group, we need to dive into its children to extract the configs
// This is handy to conditionally define a group of screens
acc.push(
...getRouteConfigsFromChildren<State, ScreenOptions, EventMap>(
child.props.children
child.props.children,
child.type !== Group
? options
: options != null
? [...options, child.props.screenOptions]
: [child.props.screenOptions]
)
);
return acc;
@@ -91,7 +99,7 @@ const getRouteConfigsFromChildren = <
}
throw new Error(
`A navigator can only contain 'Screen' components as its direct children (found ${
`A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found ${
React.isValidElement(child)
? `'${
typeof child.type === 'string' ? child.type : child.type?.name
@@ -107,7 +115,7 @@ const getRouteConfigsFromChildren = <
if (process.env.NODE_ENV !== 'production') {
configs.forEach((config) => {
const { name, children, component, getComponent } = config;
const { name, children, component, getComponent } = config[1];
if (typeof name !== 'string' || !name) {
throw new Error(
@@ -220,25 +228,22 @@ export default function useNavigationBuilder<
>(children);
const screens = routeConfigs.reduce<
Record<
string,
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>
>
Record<string, ScreenConfigWithParent<State, ScreenOptions, EventMap>>
>((acc, config) => {
if (config.name in acc) {
if (config[1].name in acc) {
throw new Error(
`A navigator cannot contain multiple 'Screen' components with the same name (found duplicate screen named '${config.name}')`
`A navigator cannot contain multiple 'Screen' components with the same name (found duplicate screen named '${config[1].name}')`
);
}
acc[config.name] = config;
acc[config[1].name] = config;
return acc;
}, {});
const routeNames = routeConfigs.map((config) => config.name);
const routeNames = routeConfigs.map((config) => config[1].name);
const routeParamList = routeNames.reduce<Record<string, object | undefined>>(
(acc, curr) => {
const { initialParams } = screens[curr];
const { initialParams } = screens[curr][1];
const initialParamsFromParams =
route?.params?.state == null &&
route?.params?.initial !== false &&
@@ -263,7 +268,7 @@ export default function useNavigationBuilder<
>(
(acc, curr) =>
Object.assign(acc, {
[curr]: screens[curr].getId,
[curr]: screens[curr][1].getId,
}),
{}
);
@@ -481,7 +486,7 @@ export default function useNavigationBuilder<
const listeners = ([] as (((e: any) => void) | undefined)[])
.concat(
...routeNames.map((name) => {
const { listeners } = screens[name];
const { listeners } = screens[name][1];
const map =
typeof listeners === 'function'
? listeners({ route: route as any, navigation })