From 61dbbd7d4fdce73c15d483420cc89d20febcf2b6 Mon Sep 17 00:00:00 2001 From: "satyajit.happy" Date: Sun, 21 Jul 2019 06:29:59 +0200 Subject: [PATCH] feat: add a setOptions function and useOptions hook In React Navigation, the screen options can be specified statically. If you need to configure any options based on props and state of the component, or want to update state and props based on some action such as tab press, you need to do it in a hacky way by changing params. it's way more complicated than it needs to be. It also breaks when used with HOCs which don't hoist static props, a common source of confusion. This PR adds a `setOptions` API to be able to update options directly without going through params, and an `useOptions` hook to make it easier to set them. --- README.md | 42 ++++++++++++++++++++++++- example/StackNavigator.tsx | 16 ++++++++-- example/TabNavigator.tsx | 11 ++++++- example/index.tsx | 60 +++++++++++++++++++++++------------- src/SceneView.tsx | 23 ++++++++++++-- src/createNavigator.tsx | 10 +++--- src/index.tsx | 1 + src/types.tsx | 32 +++++++++++-------- src/useDescriptors.tsx | 19 +++++++++--- src/useNavigationBuilder.tsx | 4 +-- src/useOptions.tsx | 9 ++++++ 11 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 src/useOptions.tsx diff --git a/README.md b/README.md index 11bccaa8..4d906f37 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 hook to configure screen options: + +```js +function Selection() { + const [selectedIds, setSelectedIds] = React.useState([]); + + useOptions({ + 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. @@ -173,9 +197,25 @@ And then we can use it: ```js - + ``` +To provide type-checking in the `useOptions` hook, we can provide it a type parameter: + +```ts +function Profile({ userId }: Props) { + useOptions({ + title: `${userId}'s profile`, + }); + + // Content +} +``` + Unfortunately it's not possible to verify that the type of children elements are correct since [TypeScript doesn't support type-checking JSX elements](https://github.com/microsoft/TypeScript/issues/21699). diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index 6466330a..ac1cacbe 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -28,6 +28,13 @@ type Action = } | { type: 'POP_TO_TOP' }; +export type StackNavigationOptions = { + /** + * Title text for the screen. + */ + title?: string; +}; + export type StackNavigationProp< ParamList extends ParamListBase > = NavigationProp & { @@ -236,7 +243,10 @@ const StackRouter: Router = { }; export function StackNavigator(props: Props) { - const { state, descriptors } = useNavigationBuilder(StackRouter, props); + const { state, descriptors } = useNavigationBuilder( + StackRouter, + props + ); return (
@@ -277,4 +287,6 @@ export function StackNavigator(props: Props) { ); } -export default createNavigator(StackNavigator); +export default createNavigator( + StackNavigator +); diff --git a/example/TabNavigator.tsx b/example/TabNavigator.tsx index f7afcefa..aaf6e673 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -23,6 +23,13 @@ type Action = { payload: { name?: string; key?: string; params?: object }; }; +export type TabNavigationOptions = { + /** + * Title text for the screen. + */ + title?: string; +}; + export type TabNavigationProp = NavigationProp< ParamList > & { @@ -196,4 +203,6 @@ export function TabNavigator(props: Props) { ); } -export default createNavigator(TabNavigator); +export default createNavigator( + TabNavigator +); diff --git a/example/index.tsx b/example/index.tsx index 34f5dd62..331901bc 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -6,8 +6,12 @@ import { PartialState, NavigationProp, RouteProp, + useOptions, } from '../src'; -import StackNavigator, { StackNavigationProp } from './StackNavigator'; +import StackNavigator, { + StackNavigationProp, + StackNavigationOptions, +} from './StackNavigator'; import TabNavigator, { TabNavigationProp } from './TabNavigator'; type StackParamList = { @@ -65,20 +69,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); + }, []); + + useOptions({ + title: `Count ${count}`, + }); + + return ( +
+

Second

+ + +
+ ); +}; const Fourth = ({ navigation, @@ -149,14 +167,14 @@ function App() { ({ + title: `Foo (${route.params ? route.params.author : ''})`, + })} initialParams={{ author: 'Jane' }} /> - + + {props => } + {() => ( diff --git a/src/SceneView.tsx b/src/SceneView.tsx index f320ce29..70aaf0e3 100644 --- a/src/SceneView.tsx +++ b/src/SceneView.tsx @@ -17,10 +17,19 @@ 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(props: Props) { - const { screen, route, navigation: helpers, getState, setState } = props; +export default function SceneView({ + screen, + route, + navigation: helpers, + getState, + setState, + setOptions, +}: Props) { const { performTransaction } = React.useContext(NavigationStateContext); const navigation = React.useMemo( @@ -29,8 +38,16 @@ export default function SceneView(props: Props) { 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/createNavigator.tsx b/src/createNavigator.tsx index 0276f47e..152a0d5e 100644 --- a/src/createNavigator.tsx +++ b/src/createNavigator.tsx @@ -2,17 +2,19 @@ import * as React from 'react'; import { ParamListBase, RouteConfig, TypedNavigator } from './types'; import Screen from './Screen'; -export default function createNavigator>( - RawNavigator: N -) { +export default function createNavigator< + Options extends object, + N extends React.ComponentType +>(RawNavigator: N) { return function Navigator(): TypedNavigator< ParamList, + Options, typeof RawNavigator > { return { Navigator: RawNavigator, Screen: Screen as React.ComponentType< - RouteConfig + RouteConfig >, }; }; diff --git a/src/index.tsx b/src/index.tsx index 538ba80e..61ae27ef 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,5 +4,6 @@ export { default as createNavigator } from './createNavigator'; export { default as useNavigationBuilder } from './useNavigationBuilder'; export { default as useNavigation } from './useNavigation'; +export { default as useOptions } from './useOptions'; export * from './types'; diff --git a/src/types.tsx b/src/types.tsx index ee45155a..accf9061 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -208,6 +208,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: Options): void; } & PrivateValueStore; export type RouteProp< @@ -232,7 +240,7 @@ export type CompositeNavigationProp< (B extends NavigationProp ? U : never) >; -export type Descriptor = { +export type Descriptor = { /** * Render the component associated with this route. */ @@ -244,18 +252,10 @@ export type Descriptor = { options: Options; }; -export type Options = { - /** - * Title text for the screen. - */ - title?: string; - - [key: string]: any; -}; - export type RouteConfig< ParamList extends ParamListBase = ParamListBase, - RouteName extends keyof ParamList = string + RouteName extends keyof ParamList = string, + Options extends object = object > = { /** * Route name of this screen. @@ -265,7 +265,12 @@ export type RouteConfig< /** * Navigator options for this screen. */ - options?: Options; + options?: + | Options + | ((props: { + route: RouteProp; + navigation: NavigationProp; + }) => Options); /** * Initial params object for the route. @@ -287,6 +292,7 @@ export type RouteConfig< export type TypedNavigator< ParamList extends ParamListBase, + Options extends object, Navigator extends React.ComponentType > = { Navigator: React.ComponentType< @@ -297,5 +303,5 @@ export type TypedNavigator< initialRouteName?: keyof ParamList; } >; - Screen: React.ComponentType>; + Screen: React.ComponentType>; }; diff --git a/src/useDescriptors.tsx b/src/useDescriptors.tsx index d10fedc2..47865ca0 100644 --- a/src/useDescriptors.tsx +++ b/src/useDescriptors.tsx @@ -25,9 +25,7 @@ type Options = { onRouteFocus: (key: string) => void; }; -const EMPTY_OPTIONS = Object.freeze({}); - -export default function useDescriptors({ +export default function useDescriptors({ state, screens, navigation, @@ -38,6 +36,7 @@ export default function useDescriptors({ removeActionListener, onRouteFocus, }: Options) { + const [options, setOptions] = React.useState<{ [key: string]: object }>({}); const context = React.useMemo( () => ({ navigation, @@ -69,14 +68,24 @@ export default function useDescriptors({ screen={screen} getState={getState} setState={setState} + setOptions={setOptions} /> ); }, - options: screen.options || EMPTY_OPTIONS, + options: { + ...(typeof screen.options === 'function' + ? screen.options({ + // @ts-ignore + route, + navigation, + }) + : screen.options), + ...options[route.key], + }, }; return acc; }, - {} as { [key: string]: Descriptor } + {} as { [key: string]: Descriptor } ); } diff --git a/src/useNavigationBuilder.tsx b/src/useNavigationBuilder.tsx index 2bbacb0e..8f6e89c0 100644 --- a/src/useNavigationBuilder.tsx +++ b/src/useNavigationBuilder.tsx @@ -39,7 +39,7 @@ const getRouteConfigsFromChildren = (children: React.ReactNode) => ); }, []); -export default function useNavigationBuilder( +export default function useNavigationBuilder( router: Router, options: Options ) { @@ -156,7 +156,7 @@ export default function useNavigationBuilder( actionCreators: router.actionCreators, }); - const descriptors = useDescriptors({ + const descriptors = useDescriptors({ state, screens, navigation, diff --git a/src/useOptions.tsx b/src/useOptions.tsx new file mode 100644 index 00000000..12b34ea0 --- /dev/null +++ b/src/useOptions.tsx @@ -0,0 +1,9 @@ +import useNavigation from './useNavigation'; + +export default function useOptions( + options: Options +) { + const navigation = useNavigation(); + + navigation.setOptions(options); +}