diff --git a/README.md b/README.md index 5fcfd594..8398a226 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,30 @@ A render callback which doesn't have such limitation and is easier to use for th The rendered component will receives a `navigation` prop with various helpers and a `route` prop which represents the route being rendered. +## Setting screen options + +In React Navigation, screen options can be specified in a static property on the component (`navigationOptions`). This poses few issues: + +- It's not possible to configure options based on props, state or context +- To update the props based on an action in the component (such as button press), we need to do it in a hacky way by changing params +- It breaks when used with HOCs which don't hoist static props, which is a common source of confusion + +Instead of a static property, we expose a method to configure screen options: + +```js +function Selection({ navigation }) { + const [selectedIds, setSelectedIds] = React.useState([]); + + navigation.setOptions({ + title: `${selectedIds.length} items selected`, + }); + + return setSelectedIds(ids => [...ids, id])} />; +} +``` + +This allows options to be changed based on props, state or context, and doesn't have the disadvantages of static configuration. + ## Type-checking The library exports few helper types. Each navigator also need to export a custom type for the `navigation` prop which should contain the actions they provide, .e.g. `push` for stack, `jumpTo` for tab etc. diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index ac1cacbe..f2fa5a1d 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -37,7 +37,7 @@ export type StackNavigationOptions = { export type StackNavigationProp< ParamList extends ParamListBase -> = NavigationProp & { +> = NavigationProp & { /** * Push a new screen onto the stack. * diff --git a/example/TabNavigator.tsx b/example/TabNavigator.tsx index aaf6e673..23afae58 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -31,7 +31,8 @@ export type TabNavigationOptions = { }; export type TabNavigationProp = NavigationProp< - ParamList + ParamList, + TabNavigationOptions > & { /** * Jump to an existing tab. diff --git a/example/index.tsx b/example/index.tsx index 1d5432af..4eec30f6 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -65,20 +65,34 @@ const Second = ({ StackNavigationProp, NavigationProp >; -}) => ( -
-

Second

- - -
-); +}) => { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => setCount(c => c + 1), 1000); + + return () => clearInterval(timer); + }, []); + + navigation.setOptions({ + title: `Count ${count}`, + }); + + return ( +
+

Second

+ + +
+ ); +}; const Fourth = ({ navigation, diff --git a/src/SceneView.tsx b/src/SceneView.tsx index 01e29663..70aaf0e3 100644 --- a/src/SceneView.tsx +++ b/src/SceneView.tsx @@ -17,6 +17,9 @@ type Props = { route: Route & { state?: NavigationState }; getState: () => NavigationState; setState: (state: NavigationState) => void; + setOptions: ( + cb: (options: { [key: string]: object }) => { [key: string]: object } + ) => void; }; export default function SceneView({ @@ -25,6 +28,7 @@ export default function SceneView({ navigation: helpers, getState, setState, + setOptions, }: Props) { const { performTransaction } = React.useContext(NavigationStateContext); @@ -34,8 +38,16 @@ export default function SceneView({ setParams: (params: object, target?: TargetRoute) => { helpers.setParams(params, target ? target : { key: route.key }); }, + setOptions: (options: object) => + setOptions(o => ({ + ...o, + [route.key]: { + ...o[route.key], + ...options, + }, + })), }), - [helpers, route.key] + [helpers, route.key, setOptions] ); const getCurrentState = React.useCallback(() => { diff --git a/src/__tests__/useDescriptors.test.tsx b/src/__tests__/useDescriptors.test.tsx new file mode 100644 index 00000000..e6e3214f --- /dev/null +++ b/src/__tests__/useDescriptors.test.tsx @@ -0,0 +1,218 @@ +import * as React from 'react'; +import { render, act } from 'react-native-testing-library'; +import useNavigationBuilder from '../useNavigationBuilder'; +import NavigationContainer from '../NavigationContainer'; +import Screen from '../Screen'; +import MockRouter from './__fixtures__/MockRouter'; + +jest.useFakeTimers(); + +beforeEach(() => (MockRouter.key = 0)); + +it('sets options with options prop as an object', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder<{ title?: string }>( + MockRouter, + props + ); + const { render, options } = descriptors[state.routes[state.index].key]; + + return ( +
+

{options.title}

+
{render()}
+
+ ); + }; + + const TestScreen = (): any => 'Test screen'; + + const root = render( + + + + + + + ); + + expect(root).toMatchInlineSnapshot(` +
+

+ Hello world +

+
+ Test screen +
+
+ `); +}); + +it('sets options with options prop as a fuction', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder<{ title?: string }>( + MockRouter, + props + ); + const { render, options } = descriptors[state.routes[state.index].key]; + + return ( +
+

{options.title}

+
{render()}
+
+ ); + }; + + const TestScreen = (): any => 'Test screen'; + + const root = render( + + + ({ title: route.params.author })} + initialParams={{ author: 'Jane' }} + /> + + + + ); + + expect(root).toMatchInlineSnapshot(` +
+

+ Jane +

+
+ Test screen +
+
+ `); +}); + +it('sets initial options with setOptions', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder<{ + title?: string; + color?: string; + }>(MockRouter, props); + const { render, options } = descriptors[state.routes[state.index].key]; + + return ( +
+

{options.title}

+
{render()}
+
+ ); + }; + + const TestScreen = ({ navigation }: any): any => { + navigation.setOptions({ + title: 'Hello world', + }); + + return 'Test screen'; + }; + + const root = render( + + + + {props => } + + + + + ); + + expect(root).toMatchInlineSnapshot(` +
+

+ Hello world +

+
+ Test screen +
+
+ `); +}); + +it('updates options with setOptions', () => { + const TestNavigator = (props: any) => { + const { state, descriptors } = useNavigationBuilder(MockRouter, props); + const { render, options } = descriptors[state.routes[state.index].key]; + + return ( +
+

{options.title}

+

{options.description}

+ {options.author} +
{render()}
+
+ ); + }; + + const TestScreen = ({ navigation }: any): any => { + navigation.setOptions({ + title: 'Hello world', + description: 'Something here', + }); + + React.useEffect(() => { + const timer = setTimeout(() => + navigation.setOptions({ + title: 'Hello again', + author: 'Jane', + }) + ); + + return () => clearTimeout(timer); + }); + + return 'Test screen'; + }; + + const element = ( + + + + {props => } + + + + + ); + + const root = render(element); + + act(() => jest.runAllTimers()); + + root.update(element); + + expect(root).toMatchInlineSnapshot(` +
+

+ Hello again +

+

+ Something here +

+ + Jane + +
+ Test screen +
+
+ `); +}); diff --git a/src/createNavigator.tsx b/src/createNavigator.tsx index 152a0d5e..ce6c270d 100644 --- a/src/createNavigator.tsx +++ b/src/createNavigator.tsx @@ -3,18 +3,18 @@ import { ParamListBase, RouteConfig, TypedNavigator } from './types'; import Screen from './Screen'; export default function createNavigator< - Options extends object, + ScreenOptions extends object, N extends React.ComponentType >(RawNavigator: N) { return function Navigator(): TypedNavigator< ParamList, - Options, + ScreenOptions, typeof RawNavigator > { return { Navigator: RawNavigator, Screen: Screen as React.ComponentType< - RouteConfig + RouteConfig >, }; }; diff --git a/src/types.tsx b/src/types.tsx index 235526f2..79ce2561 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -149,7 +149,10 @@ class PrivateValueStore { private __private_value_type?: T; } -export type NavigationProp = { +export type NavigationProp< + ParamList extends ParamListBase = ParamListBase, + ScreenOptions extends object = object +> = { /** * Dispatch an action or an update function to the router. * The update function will receive the current state, @@ -208,6 +211,14 @@ export type NavigationProp = { params: ParamList[RouteName], target: TargetRoute ): void; + + /** + * Update the options for the route. + * The options object will be shallow merged with default options object. + * + * @param options Options object for the route. + */ + setOptions(options: Partial): void; } & PrivateValueStore; export type RouteProp< @@ -224,15 +235,17 @@ export type RouteProp< }); export type CompositeNavigationProp< - A extends NavigationProp, - B extends NavigationProp -> = Omit> & + A extends NavigationProp, + B extends NavigationProp +> = Omit> & NavigationProp< - (A extends NavigationProp ? T : never) & - (B extends NavigationProp ? U : never) + (A extends NavigationProp ? T : never) & + (B extends NavigationProp ? U : never), + (A extends NavigationProp ? O : never) & + (B extends NavigationProp ? P : never) >; -export type Descriptor = { +export type Descriptor = { /** * Render the component associated with this route. */ @@ -241,13 +254,13 @@ export type Descriptor = { /** * Options for the route. */ - options: Options; + options: ScreenOptions; }; export type RouteConfig< ParamList extends ParamListBase = ParamListBase, RouteName extends keyof ParamList = string, - Options extends object = object + ScreenOptions extends object = object > = { /** * Route name of this screen. @@ -258,11 +271,11 @@ export type RouteConfig< * Navigator options for this screen. */ options?: - | Options + | ScreenOptions | ((props: { route: RouteProp; navigation: NavigationProp; - }) => Options); + }) => ScreenOptions); /** * Initial params object for the route. @@ -284,7 +297,7 @@ export type RouteConfig< export type TypedNavigator< ParamList extends ParamListBase, - Options extends object, + ScreenOptions extends object, Navigator extends React.ComponentType > = { Navigator: React.ComponentType< @@ -295,5 +308,7 @@ export type TypedNavigator< initialRouteName?: keyof ParamList; } >; - Screen: React.ComponentType>; + Screen: React.ComponentType< + RouteConfig + >; }; diff --git a/src/useDescriptors.tsx b/src/useDescriptors.tsx index 74fccd6e..47865ca0 100644 --- a/src/useDescriptors.tsx +++ b/src/useDescriptors.tsx @@ -36,6 +36,7 @@ export default function useDescriptors({ removeActionListener, onRouteFocus, }: Options) { + const [options, setOptions] = React.useState<{ [key: string]: object }>({}); const context = React.useMemo( () => ({ navigation, @@ -67,6 +68,7 @@ export default function useDescriptors({ screen={screen} getState={getState} setState={setState} + setOptions={setOptions} /> ); @@ -79,6 +81,7 @@ export default function useDescriptors({ navigation, }) : screen.options), + ...options[route.key], }, }; return acc;