feat: add ability add listeners with listeners prop

This adds ability to listen to events from the component where the navigator is defined, even if the screen is not rendered.

```js
<Tabs.Screen
  name="Chat"
  component={Chat}
  options={{ title: 'Chat' }}
  listeners={{
    tabPress: e => console.log('Tab press', e.target),
  }}
/>
```

Closes #6756
This commit is contained in:
Satyajit Sahoo
2020-02-25 20:11:48 +01:00
parent 028c2887c6
commit 162410843c
15 changed files with 351 additions and 32 deletions

View File

@@ -10,10 +10,14 @@ import NavigationContext from './NavigationContext';
import NavigationRouteContext from './NavigationRouteContext';
import StaticContainer from './StaticContainer';
import EnsureSingleNavigator from './EnsureSingleNavigator';
import { NavigationProp, RouteConfig } from './types';
import { NavigationProp, RouteConfig, EventMapBase } from './types';
type Props<State extends NavigationState, ScreenOptions extends object> = {
screen: RouteConfig<ParamListBase, string, ScreenOptions>;
type Props<
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase
> = {
screen: RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>;
navigation: NavigationProp<ParamListBase, string, State, ScreenOptions>;
route: Route<string> & {
state?: NavigationState | PartialState<NavigationState>;
@@ -28,14 +32,15 @@ type Props<State extends NavigationState, ScreenOptions extends object> = {
*/
export default function SceneView<
State extends NavigationState,
ScreenOptions extends object
ScreenOptions extends object,
EventMap extends EventMapBase
>({
screen,
route,
navigation,
getState,
setState,
}: Props<State, ScreenOptions>) {
}: Props<State, ScreenOptions, EventMap>) {
const navigatorKeyRef = React.useRef<string | undefined>();
const getKey = React.useCallback(() => navigatorKeyRef.current, []);

View File

@@ -1,5 +1,5 @@
import { ParamListBase } from '@react-navigation/routers';
import { RouteConfig } from './types';
import { ParamListBase, NavigationState } from '@react-navigation/routers';
import { RouteConfig, EventMapBase } from './types';
/**
* Empty component used for specifying route configuration.
@@ -7,8 +7,10 @@ import { RouteConfig } from './types';
export default function Screen<
ParamList extends ParamListBase,
RouteName extends keyof ParamList,
ScreenOptions extends object
>(_: RouteConfig<ParamList, RouteName, ScreenOptions>) {
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase
>(_: RouteConfig<ParamList, RouteName, State, ScreenOptions, EventMap>) {
/* istanbul ignore next */
return null;
}

View File

@@ -362,7 +362,7 @@ it('fires blur event when a route is removed with a delay', async () => {
expect(blurCallback).toBeCalledTimes(1);
});
it('fires custom events', () => {
it('fires custom events added with addListener', () => {
const eventName = 'someSuperCoolEvent';
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
@@ -409,10 +409,13 @@ it('fires custom events', () => {
expect(secondCallback).toBeCalledTimes(0);
expect(thirdCallback).toBeCalledTimes(0);
const target =
ref.current.state.routes[ref.current.state.routes.length - 1].key;
act(() => {
ref.current.navigation.emit({
type: eventName,
target: ref.current.state.routes[ref.current.state.routes.length - 1].key,
target,
data: 42,
});
});
@@ -422,6 +425,7 @@ it('fires custom events', () => {
expect(thirdCallback).toBeCalledTimes(1);
expect(thirdCallback.mock.calls[0][0].type).toBe('someSuperCoolEvent');
expect(thirdCallback.mock.calls[0][0].data).toBe(42);
expect(thirdCallback.mock.calls[0][0].target).toBe(target);
expect(thirdCallback.mock.calls[0][0].defaultPrevented).toBe(undefined);
expect(thirdCallback.mock.calls[0][0].preventDefault).toBe(undefined);
@@ -429,11 +433,197 @@ it('fires custom events', () => {
ref.current.navigation.emit({ type: eventName });
});
expect(firstCallback.mock.calls[0][0].target).toBe(undefined);
expect(secondCallback.mock.calls[0][0].target).toBe(undefined);
expect(thirdCallback.mock.calls[1][0].target).toBe(undefined);
expect(firstCallback).toBeCalledTimes(1);
expect(secondCallback).toBeCalledTimes(1);
expect(thirdCallback).toBeCalledTimes(2);
});
it("doesn't call same listener multiple times with addListener", () => {
const eventName = 'someSuperCoolEvent';
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
const { state, navigation, descriptors } = useNavigationBuilder(
MockRouter,
props
);
React.useImperativeHandle(ref, () => ({ navigation, state }), [
navigation,
state,
]);
return state.routes.map(route => descriptors[route.key].render());
});
const callback = jest.fn();
const Test = ({ navigation }: any) => {
React.useEffect(() => navigation.addListener(eventName, callback), [
navigation,
]);
return null;
};
const ref = React.createRef<any>();
const element = (
<BaseNavigationContainer>
<TestNavigator ref={ref}>
<Screen name="first" component={Test} />
<Screen name="second" component={Test} />
<Screen name="third" component={Test} />
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
expect(callback).toBeCalledTimes(0);
act(() => {
ref.current.navigation.emit({ type: eventName });
});
expect(callback).toBeCalledTimes(1);
});
it('fires custom events added with listeners prop', () => {
const eventName = 'someSuperCoolEvent';
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
const { state, navigation } = useNavigationBuilder(MockRouter, props);
React.useImperativeHandle(ref, () => ({ navigation, state }), [
navigation,
state,
]);
return null;
});
const firstCallback = jest.fn();
const secondCallback = jest.fn();
const thirdCallback = jest.fn();
const ref = React.createRef<any>();
const element = (
<BaseNavigationContainer>
<TestNavigator ref={ref}>
<Screen
name="first"
listeners={{ someSuperCoolEvent: firstCallback }}
component={jest.fn()}
/>
<Screen
name="second"
listeners={{ someSuperCoolEvent: secondCallback }}
component={jest.fn()}
/>
<Screen
name="third"
listeners={{ someSuperCoolEvent: thirdCallback }}
component={jest.fn()}
/>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
expect(firstCallback).toBeCalledTimes(0);
expect(secondCallback).toBeCalledTimes(0);
expect(thirdCallback).toBeCalledTimes(0);
const target =
ref.current.state.routes[ref.current.state.routes.length - 1].key;
act(() => {
ref.current.navigation.emit({
type: eventName,
target,
data: 42,
});
});
expect(firstCallback).toBeCalledTimes(0);
expect(secondCallback).toBeCalledTimes(0);
expect(thirdCallback).toBeCalledTimes(1);
expect(thirdCallback.mock.calls[0][0].type).toBe('someSuperCoolEvent');
expect(thirdCallback.mock.calls[0][0].data).toBe(42);
expect(thirdCallback.mock.calls[0][0].target).toBe(target);
expect(thirdCallback.mock.calls[0][0].defaultPrevented).toBe(undefined);
expect(thirdCallback.mock.calls[0][0].preventDefault).toBe(undefined);
act(() => {
ref.current.navigation.emit({ type: eventName });
});
expect(firstCallback.mock.calls[0][0].target).toBe(undefined);
expect(secondCallback.mock.calls[0][0].target).toBe(undefined);
expect(thirdCallback.mock.calls[1][0].target).toBe(undefined);
expect(firstCallback).toBeCalledTimes(1);
expect(secondCallback).toBeCalledTimes(1);
expect(thirdCallback).toBeCalledTimes(2);
});
it("doesn't call same listener multiple times with listeners", () => {
const eventName = 'someSuperCoolEvent';
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
const { state, navigation } = useNavigationBuilder(MockRouter, props);
React.useImperativeHandle(ref, () => ({ navigation, state }), [
navigation,
state,
]);
return null;
});
const callback = jest.fn();
const ref = React.createRef<any>();
const element = (
<BaseNavigationContainer>
<TestNavigator ref={ref}>
<Screen
name="first"
listeners={{ someSuperCoolEvent: callback }}
component={jest.fn()}
/>
<Screen
name="second"
listeners={{ someSuperCoolEvent: callback }}
component={jest.fn()}
/>
<Screen
name="third"
listeners={{ someSuperCoolEvent: callback }}
component={jest.fn()}
/>
</TestNavigator>
</BaseNavigationContainer>
);
render(element);
expect(callback).toBeCalledTimes(0);
act(() => {
ref.current.navigation.emit({ type: eventName });
});
expect(callback).toBeCalledTimes(1);
});
it('has option to prevent default', () => {
expect.assertions(5);

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { ParamListBase } from '@react-navigation/routers';
import { ParamListBase, NavigationState } from '@react-navigation/routers';
import Screen from './Screen';
import { TypedNavigator } from './types';
import { TypedNavigator, EventMapBase } from './types';
/**
* Higher order component to create a `Navigator` and `Screen` pair.
@@ -11,12 +11,16 @@ import { TypedNavigator } from './types';
* @returns Factory method to create a `Navigator` and `Screen` pair.
*/
export default function createNavigatorFactory<
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase,
NavigatorComponent extends React.ComponentType<any>
>(Navigator: NavigatorComponent) {
return function<ParamList extends ParamListBase>(): TypedNavigator<
ParamList,
State,
ScreenOptions,
EventMap,
typeof Navigator
> {
if (arguments[0] !== undefined) {

View File

@@ -48,6 +48,7 @@ export type EventArg<
* Type of the event (e.g. `focus`, `blur`)
*/
readonly type: EventName;
readonly target?: string;
} & (CanPreventDefault extends true
? {
/**
@@ -360,7 +361,9 @@ export type Descriptor<
export type RouteConfig<
ParamList extends ParamListBase,
RouteName extends keyof ParamList,
ScreenOptions extends object
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase
> = {
/**
* Route name of this screen.
@@ -377,6 +380,16 @@ export type RouteConfig<
navigation: any;
}) => ScreenOptions);
/**
* Event listeners for this screen.
*/
listeners?: Partial<
{
[EventName in keyof (EventMap &
EventMapCore<State>)]: EventListenerCallback<EventMap, EventName>;
}
>;
/**
* Initial params object for the route.
*/
@@ -420,7 +433,9 @@ export type NavigationContainerRef =
export type TypedNavigator<
ParamList extends ParamListBase,
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase,
Navigator extends React.ComponentType<any>
> = {
/**
@@ -451,6 +466,6 @@ export type TypedNavigator<
* Component used for specifying route configuration.
*/
Screen: <RouteName extends keyof ParamList>(
_: RouteConfig<ParamList, RouteName, ScreenOptions>
_: RouteConfig<ParamList, RouteName, State, ScreenOptions, EventMap>
) => null;
};

View File

@@ -13,11 +13,24 @@ import NavigationBuilderContext, {
} from './NavigationBuilderContext';
import { NavigationEventEmitter } from './useEventEmitter';
import useNavigationCache from './useNavigationCache';
import { Descriptor, NavigationHelpers, RouteConfig, RouteProp } from './types';
import {
Descriptor,
NavigationHelpers,
RouteConfig,
RouteProp,
EventMapBase,
} from './types';
type Options<State extends NavigationState, ScreenOptions extends object> = {
type Options<
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase
> = {
state: State;
screens: Record<string, RouteConfig<ParamListBase, string, ScreenOptions>>;
screens: Record<
string,
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>
>;
navigation: NavigationHelpers<ParamListBase>;
screenOptions?:
| ScreenOptions
@@ -49,7 +62,8 @@ type Options<State extends NavigationState, ScreenOptions extends object> = {
*/
export default function useDescriptors<
State extends NavigationState,
ScreenOptions extends object
ScreenOptions extends object,
EventMap extends EventMapBase
>({
state,
screens,
@@ -64,7 +78,7 @@ export default function useDescriptors<
onRouteFocus,
router,
emitter,
}: Options<State, ScreenOptions>) {
}: Options<State, ScreenOptions, EventMap>) {
const [options, setOptions] = React.useState<Record<string, object>>({});
const { trackAction } = React.useContext(NavigationBuilderContext);
@@ -133,6 +147,7 @@ export default function useDescriptors<
: screen.options({
// @ts-ignore
route,
// @ts-ignore
navigation,
})),
// The options set via `navigation.setOptions`

View File

@@ -5,12 +5,20 @@ export type NavigationEventEmitter = EventEmitter<Record<string, any>> & {
create: (target: string) => EventConsumer<Record<string, any>>;
};
type Listeners = ((data: any) => void)[];
type Listeners = ((e: any) => void)[];
/**
* Hook to manage the event system used by the navigator to notify screens of various events.
*/
export default function useEventEmitter(): NavigationEventEmitter {
export default function useEventEmitter(
listen?: (e: any) => void
): NavigationEventEmitter {
const listenRef = React.useRef(listen);
React.useEffect(() => {
listenRef.current = listen;
});
const listeners = React.useRef<Record<string, Record<string, Listeners>>>({});
const create = React.useCallback((target: string) => {
@@ -60,7 +68,9 @@ export default function useEventEmitter(): NavigationEventEmitter {
const callbacks =
target !== undefined
? items[target] && items[target].slice()
: ([] as Listeners).concat(...Object.keys(items).map(t => items[t]));
: ([] as Listeners)
.concat(...Object.keys(items).map(t => items[t]))
.filter((cb, i, self) => self.lastIndexOf(cb) === i);
const event: EventArg<any, any, any> = {
get type() {
@@ -68,8 +78,18 @@ export default function useEventEmitter(): NavigationEventEmitter {
},
};
if (target !== undefined) {
Object.defineProperty(event, 'target', {
enumerable: true,
get() {
return target;
},
});
}
if (data !== undefined) {
Object.defineProperty(event, 'data', {
enumerable: true,
get() {
return data;
},
@@ -81,11 +101,13 @@ export default function useEventEmitter(): NavigationEventEmitter {
Object.defineProperties(event, {
defaultPrevented: {
enumerable: true,
get() {
return defaultPrevented;
},
},
preventDefault: {
enumerable: true,
value() {
defaultPrevented = true;
},
@@ -93,6 +115,8 @@ export default function useEventEmitter(): NavigationEventEmitter {
});
}
listenRef.current?.(event);
callbacks?.forEach(cb => cb(event));
return event as any;

View File

@@ -27,6 +27,7 @@ import {
DefaultNavigatorOptions,
RouteConfig,
PrivateValueStore,
EventMapBase,
} from './types';
import useStateGetters from './useStateGetters';
import useOnGetState from './useOnGetState';
@@ -55,18 +56,28 @@ const isArrayEqual = (a: any[], b: any[]) =>
*
* @param children React Elements to extract the config from.
*/
const getRouteConfigsFromChildren = <ScreenOptions extends object>(
const getRouteConfigsFromChildren = <
State extends NavigationState,
ScreenOptions extends object,
EventMap extends EventMapBase
>(
children: React.ReactNode
) => {
const configs = React.Children.toArray(children).reduce<
RouteConfig<ParamListBase, string, ScreenOptions>[]
RouteConfig<ParamListBase, string, 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(
child.props as RouteConfig<ParamListBase, string, ScreenOptions>
child.props as RouteConfig<
ParamListBase,
string,
State,
ScreenOptions,
EventMap
>
);
return acc;
}
@@ -75,7 +86,9 @@ const getRouteConfigsFromChildren = <ScreenOptions extends object>(
// When we encounter a fragment, we need to dive into its children to extract the configs
// This is handy to conditionally define a group of screens
acc.push(
...getRouteConfigsFromChildren<ScreenOptions>(child.props.children)
...getRouteConfigsFromChildren<State, ScreenOptions, EventMap>(
child.props.children
)
);
return acc;
}
@@ -177,9 +190,17 @@ export default function useNavigationBuilder<
})
);
const routeConfigs = getRouteConfigsFromChildren<ScreenOptions>(children);
const routeConfigs = getRouteConfigsFromChildren<
State,
ScreenOptions,
EventMap
>(children);
const screens = routeConfigs.reduce<
Record<string, RouteConfig<ParamListBase, string, ScreenOptions>>
Record<
string,
RouteConfig<ParamListBase, string, State, ScreenOptions, EventMap>
>
>((acc, config) => {
if (config.name in acc) {
throw new Error(
@@ -344,7 +365,35 @@ export default function useNavigationBuilder<
: (initializedStateRef.current as State);
}, [getCurrentState, isStateInitialized]);
const emitter = useEventEmitter();
const emitter = useEventEmitter(e => {
let routeNames = [];
if (e.target) {
const name = state.routes.find(route => route.key === e.target)?.name;
if (name) {
routeNames.push(name);
}
} else {
routeNames.push(...Object.keys(screens));
}
const listeners = ([] as (((e: any) => void) | undefined)[])
.concat(
...routeNames.map(name => {
const { listeners } = screens[name];
return listeners
? Object.keys(listeners)
.filter(type => type === e.type)
.map(type => listeners[type])
: undefined;
})
)
.filter((cb, i, self) => cb && self.lastIndexOf(cb) === i);
listeners.forEach(listener => listener?.(e));
});
useFocusEvents({ state, emitter });
@@ -400,7 +449,7 @@ export default function useNavigationBuilder<
getStateForRoute,
});
const descriptors = useDescriptors<State, ScreenOptions>({
const descriptors = useDescriptors<State, ScreenOptions, EventMap>({
state,
screens,
navigation,