feat: add focus and blur events

This commit is contained in:
satyajit.happy
2019-07-28 19:23:10 +02:00
committed by Satyajit Sahoo
parent f130d3c292
commit fb8d3024bf
12 changed files with 564 additions and 32 deletions

View File

@@ -133,6 +133,36 @@ function Selection({ navigation }) {
This allows options to be changed based on props, state or context, and doesn't have the disadvantages of static configuration.
## Navigation events
Screens can add listeners on the `navigation` prop like in React Navigation. By default, `focus` and `blur` events are fired when focused screen changes:
```js
function Profile({ navigation }) {
React.useEffect(() =>
navigation.addListener('focus', () => {
// do something
})
);
return <ProfileContent />;
}
```
Navigators can also emit custom events using the `emit` method in the `navigation` object passed:
```js
navigation.emit({
type: 'transitionStart',
data: { blurring: false },
target: route.key,
});
```
The `target` property determines the screen that will receive the event. If the `target` property is omitted, the event is dispatched to all screens in the navigator.
Screens cannot emit events as there is no `emit` method on a screen's `navigation` prop.
## 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.

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { NavigationHelpers, ParamListBase } from './types';
import { NavigationProp, ParamListBase } from './types';
const NavigationContext = React.createContext<
NavigationHelpers<ParamListBase> | undefined
NavigationProp<ParamListBase> | undefined
>(undefined);
export default NavigationContext;

View File

@@ -76,13 +76,25 @@ export default function MockRouter(options: DefaultRouterOptions) {
case 'NOOP':
return state;
case 'NAVIGATE': {
const index = state.routes.findIndex(
route => route.name === action.payload.name
);
if (index === -1) {
return null;
}
return { ...state, index };
}
default:
return BaseRouter.getStateForAction(state, action);
}
},
shouldActionPropagateToChildren() {
return false;
shouldActionPropagateToChildren(action) {
return action.type === 'NAVIGATE';
},
shouldActionChangeFocus() {

View File

@@ -0,0 +1,268 @@
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';
it('fires focus and blur events in root navigator', () => {
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
const { state, navigation, descriptors } = useNavigationBuilder(
MockRouter,
props
);
React.useImperativeHandle(ref, () => navigation, [navigation]);
return state.routes.map(route => descriptors[route.key].render());
});
const firstFocusCallback = jest.fn();
const firstBlurCallback = jest.fn();
const secondFocusCallback = jest.fn();
const secondBlurCallback = jest.fn();
const thirdFocusCallback = jest.fn();
const thirdBlurCallback = jest.fn();
const fourthFocusCallback = jest.fn();
const fourthBlurCallback = jest.fn();
const createComponent = (focusCallback: any, blurCallback: any) => ({
navigation,
}: any) => {
React.useEffect(() => navigation.addListener('focus', focusCallback), [
navigation,
]);
React.useEffect(() => navigation.addListener('blur', blurCallback), [
navigation,
]);
return null;
};
const navigation = React.createRef<any>();
const element = (
<NavigationContainer>
<TestNavigator ref={navigation}>
<Screen
name="first"
component={createComponent(firstFocusCallback, firstBlurCallback)}
/>
<Screen
name="second"
component={createComponent(secondFocusCallback, secondBlurCallback)}
/>
<Screen
name="third"
component={createComponent(thirdFocusCallback, thirdBlurCallback)}
/>
<Screen
name="fourth"
component={createComponent(fourthFocusCallback, fourthBlurCallback)}
/>
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(firstFocusCallback).toBeCalledTimes(1);
expect(firstBlurCallback).toBeCalledTimes(0);
expect(secondFocusCallback).toBeCalledTimes(0);
expect(secondBlurCallback).toBeCalledTimes(0);
expect(thirdFocusCallback).toBeCalledTimes(0);
expect(thirdBlurCallback).toBeCalledTimes(0);
expect(fourthFocusCallback).toBeCalledTimes(0);
expect(fourthBlurCallback).toBeCalledTimes(0);
act(() => navigation.current.navigate('second'));
expect(firstBlurCallback).toBeCalledTimes(1);
expect(secondFocusCallback).toBeCalledTimes(1);
act(() => navigation.current.navigate('fourth'));
expect(firstFocusCallback).toBeCalledTimes(1);
expect(firstBlurCallback).toBeCalledTimes(1);
expect(secondFocusCallback).toBeCalledTimes(1);
expect(secondBlurCallback).toBeCalledTimes(1);
expect(thirdFocusCallback).toBeCalledTimes(0);
expect(thirdBlurCallback).toBeCalledTimes(0);
expect(fourthFocusCallback).toBeCalledTimes(1);
expect(fourthBlurCallback).toBeCalledTimes(0);
});
it('fires focus and blur events in nested navigator', () => {
const TestNavigator = React.forwardRef((props: any, ref: any): any => {
const { state, navigation, descriptors } = useNavigationBuilder(
MockRouter,
props
);
React.useImperativeHandle(ref, () => navigation, [navigation]);
return state.routes.map(route => descriptors[route.key].render());
});
const firstFocusCallback = jest.fn();
const firstBlurCallback = jest.fn();
const secondFocusCallback = jest.fn();
const secondBlurCallback = jest.fn();
const thirdFocusCallback = jest.fn();
const thirdBlurCallback = jest.fn();
const fourthFocusCallback = jest.fn();
const fourthBlurCallback = jest.fn();
const createComponent = (focusCallback: any, blurCallback: any) => ({
navigation,
}: any) => {
React.useEffect(() => navigation.addListener('focus', focusCallback), [
navigation,
]);
React.useEffect(() => navigation.addListener('blur', blurCallback), [
navigation,
]);
return null;
};
const parent = React.createRef<any>();
const child = React.createRef<any>();
const element = (
<NavigationContainer>
<TestNavigator ref={parent}>
<Screen
name="first"
component={createComponent(firstFocusCallback, firstBlurCallback)}
/>
<Screen
name="second"
component={createComponent(secondFocusCallback, secondBlurCallback)}
/>
<Screen name="nested">
{() => (
<TestNavigator ref={child}>
<Screen
name="third"
component={createComponent(
thirdFocusCallback,
thirdBlurCallback
)}
/>
<Screen
name="fourth"
component={createComponent(
fourthFocusCallback,
fourthBlurCallback
)}
/>
</TestNavigator>
)}
</Screen>
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(thirdFocusCallback).toBeCalledTimes(0);
expect(firstFocusCallback).toBeCalledTimes(1);
act(() => child.current.navigate('fourth'));
expect(firstFocusCallback).toBeCalledTimes(1);
expect(fourthFocusCallback).toBeCalledTimes(0);
expect(thirdFocusCallback).toBeCalledTimes(0);
act(() => parent.current.navigate('second'));
expect(thirdFocusCallback).toBeCalledTimes(0);
expect(secondFocusCallback).toBeCalledTimes(1);
act(() => parent.current.navigate('nested'));
expect(firstBlurCallback).toBeCalledTimes(1);
expect(thirdFocusCallback).toBeCalledTimes(0);
expect(fourthFocusCallback).toBeCalledTimes(1);
act(() => parent.current.navigate('third'));
expect(fourthBlurCallback).toBeCalledTimes(1);
expect(thirdFocusCallback).toBeCalledTimes(1);
});
it('fires custom events', () => {
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 firstCallback = jest.fn();
const secondCallback = jest.fn();
const thirdCallback = jest.fn();
const createComponent = (callback: any) => ({ navigation }: any) => {
React.useEffect(() => navigation.addListener(eventName, callback), [
navigation,
]);
return null;
};
const ref = React.createRef<any>();
const element = (
<NavigationContainer>
<TestNavigator ref={ref}>
<Screen name="first" component={createComponent(firstCallback)} />
<Screen name="second" component={createComponent(secondCallback)} />
<Screen name="third" component={createComponent(thirdCallback)} />
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(firstCallback).toBeCalledTimes(0);
expect(secondCallback).toBeCalledTimes(0);
expect(thirdCallback).toBeCalledTimes(0);
act(() =>
ref.current.navigation.emit({
type: eventName,
target: ref.current.state.routes[ref.current.state.routes.length - 1].key,
data: 42,
})
);
expect(firstCallback).toBeCalledTimes(0);
expect(secondCallback).toBeCalledTimes(0);
expect(thirdCallback).toBeCalledTimes(1);
expect(thirdCallback.mock.calls[0][0]).toBe(42);
act(() => ref.current.navigation.emit({ type: eventName }));
expect(firstCallback).toBeCalledTimes(1);
expect(secondCallback).toBeCalledTimes(1);
expect(thirdCallback).toBeCalledTimes(2);
});

View File

@@ -162,7 +162,35 @@ export type Router<
export type ParamListBase = { [key: string]: object | undefined };
class PrivateValueStore<A, B> {
export type EventMapBase = {
focus: undefined;
blur: undefined;
};
export type EventListenerCallback<Data> = Data extends undefined
? () => void
: (data: Data) => void;
export type EventConsumer<EventMap extends { [key: string]: any }> = {
addListener<EventName extends Extract<keyof EventMap, string>>(
type: EventName,
callback: EventListenerCallback<EventMap[EventName]>
): () => void;
removeListener<EventName extends Extract<keyof EventMap, string>>(
type: EventName,
callback: EventListenerCallback<EventMap[EventName]>
): void;
};
export type EventEmitter<EventMap extends { [key: string]: any }> = {
emit<EventName extends Extract<keyof EventMap, string>>(options: {
type: EventName;
data?: EventMap[EventName];
target?: string;
}): void;
};
class PrivateValueStore<A, B, C> {
/**
* TypeScript requires a type to be actually used to be able to infer it.
* This is a hacky way of storing type in a property without surfacing it in intellisense.
@@ -171,6 +199,8 @@ class PrivateValueStore<A, B> {
private __private_value_type_a?: A;
// @ts-ignore
private __private_value_type_b?: B;
// @ts-ignore
private __private_value_type_c?: C;
}
type NavigationHelpersCommon<
@@ -221,29 +251,38 @@ type NavigationHelpersCommon<
* Go back to the previous route in history.
*/
goBack(): void;
} & PrivateValueStore<ParamList, keyof ParamList>;
/**
* Check if the screen is focused. The method returns `true` if focused, `false` otherwise.
* Note that this method doesn't re-render screen when the focus changes. So don't use it in `render`.
* To get notified of focus changes, use `addListener('focus', cb)` and `addListener('blur', cb)`.
*/
isFocused(): boolean;
} & PrivateValueStore<ParamList, keyof ParamList, {}>;
export type NavigationHelpers<
ParamList extends ParamListBase
> = NavigationHelpersCommon<ParamList> & {
/**
* Update the param object for the route.
* The new params will be shallow merged with the old one.
*
* @param params Params object for the current route.
* @param key Key of the route for updating params.
*/
setParams<RouteName extends keyof ParamList>(
params: ParamList[RouteName],
key: string
): void;
};
> = NavigationHelpersCommon<ParamList> &
EventEmitter<{ [key: string]: any }> & {
/**
* Update the param object for the route.
* The new params will be shallow merged with the old one.
*
* @param params Params object for the current route.
* @param key Key of the route for updating params.
*/
setParams<RouteName extends keyof ParamList>(
params: ParamList[RouteName],
key: string
): void;
};
export type NavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = string,
State extends NavigationState = NavigationState,
ScreenOptions extends object = {}
ScreenOptions extends object = {},
EventMap extends { [key: string]: any } = {}
> = NavigationHelpersCommon<ParamList, State> & {
/**
* Update the param object for the route.
@@ -262,7 +301,8 @@ export type NavigationProp<
* @param options Options object for the route.
*/
setOptions(options: Partial<ScreenOptions>): void;
} & PrivateValueStore<ParamList, RouteName>;
} & EventConsumer<EventMap & EventMapBase> &
PrivateValueStore<ParamList, RouteName, EventMap>;
export type RouteProp<
ParamList extends ParamListBase,
@@ -280,7 +320,7 @@ export type RouteProp<
export type CompositeNavigationProp<
A extends NavigationProp<ParamListBase>,
B extends NavigationHelpersCommon<ParamListBase>
> = Omit<A & B, keyof NavigationProp<any, any, any>> &
> = Omit<A & B, keyof NavigationProp<any>> &
NavigationProp<
/**
* Param list from both navigation objects needs to be combined
@@ -299,9 +339,15 @@ export type CompositeNavigationProp<
A extends NavigationProp<any, any, infer S> ? S : NavigationState,
/**
* Screen options from both navigation objects needs to be combined
* This allows typechecking `setOptions`
*/
(A extends NavigationProp<any, any, any, infer O> ? O : {}) &
(B extends NavigationProp<any, any, any, infer P> ? P : {})
(B extends NavigationProp<any, any, any, infer P> ? P : {}),
/**
* Event consumer config should refer to the config specified in the first type
* This allows typechecking `addListener`/`removeListener`
*/
A extends NavigationProp<any, any, any, any, infer E> ? E : {}
>;
export type Descriptor<ScreenOptions extends object> = {
@@ -333,7 +379,7 @@ export type RouteConfig<
| ScreenOptions
| ((props: {
route: RouteProp<ParamList, RouteName>;
navigation: NavigationHelpers<ParamList>;
navigation: NavigationHelpersCommon<ParamList>;
}) => ScreenOptions);
/**

View File

@@ -3,6 +3,7 @@ import SceneView from './SceneView';
import NavigationBuilderContext, {
ChildActionListener,
} from './NavigationBuilderContext';
import { NavigationEventEmitter } from './useEventEmitter';
import useNavigationCache from './useNavigationCache';
import {
Descriptor,
@@ -27,6 +28,7 @@ type Options<ScreenOptions extends object> = {
addActionListener: (listener: ChildActionListener) => void;
removeActionListener: (listener: ChildActionListener) => void;
onRouteFocus: (key: string) => void;
emitter: NavigationEventEmitter;
};
export default function useDescriptors<ScreenOptions extends object>({
@@ -39,6 +41,7 @@ export default function useDescriptors<ScreenOptions extends object>({
addActionListener,
removeActionListener,
onRouteFocus,
emitter,
}: Options<ScreenOptions>) {
const [options, setOptions] = React.useState<{ [key: string]: object }>({});
const context = React.useMemo(
@@ -60,8 +63,10 @@ export default function useDescriptors<ScreenOptions extends object>({
const navigations = useNavigationCache({
state,
getState,
navigation,
setOptions,
emitter,
});
return state.routes.reduce(

60
src/useEventEmitter.tsx Normal file
View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { EventEmitter, EventConsumer } from './types';
export type NavigationEventEmitter = EventEmitter<{ [key: string]: any }> & {
create: (target: string) => EventConsumer<{ [key: string]: any }>;
};
type Listeners = Array<(data: any) => void>;
export default function useEventEmitter(): NavigationEventEmitter {
const listeners = React.useRef<{
[key: string]: { [key: string]: Listeners };
}>({});
const create = React.useCallback((target: string) => {
const removeListener = (type: string, callback: (data: any) => void) => {
const callbacks = listeners.current[type]
? listeners.current[type][target]
: undefined;
if (!callbacks) {
return;
}
const index = callbacks.indexOf(callback);
callbacks.splice(index, 1);
};
const addListener = (type: string, callback: (data: any) => void) => {
listeners.current[type] = listeners.current[type] || {};
listeners.current[type][target] = listeners.current[type][target] || [];
listeners.current[type][target].push(callback);
return () => removeListener(type, callback);
};
return {
addListener,
removeListener,
};
}, []);
const emit = React.useCallback(
({ type, data, target }: { type: string; data?: any; target?: string }) => {
const items = listeners.current[type] || {};
// Copy the current list of callbacks in case they are mutated during execution
const callbacks =
target !== undefined
? items[target] && items[target].slice()
: ([] as Listeners).concat(...Object.keys(items).map(t => items[t]));
callbacks && callbacks.forEach(cb => cb(data));
},
[]
);
return React.useMemo(() => ({ create, emit }), [create, emit]);
}

70
src/useFocusEvents.tsx Normal file
View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import NavigationContext from './NavigationContext';
import { NavigationEventEmitter } from './useEventEmitter';
import { NavigationState } from './types';
type Options = {
state: NavigationState;
emitter: NavigationEventEmitter;
};
export default function useFocusEvents({ state, emitter }: Options) {
const navigation = React.useContext(NavigationContext);
const lastFocusedKeyRef = React.useRef<string | undefined>();
const currentFocusedKey = state.routes[state.index].key;
React.useEffect(
() =>
navigation &&
navigation.addListener('focus', () =>
emitter.emit({ type: 'focus', target: currentFocusedKey })
),
[currentFocusedKey, emitter, navigation]
);
React.useEffect(
() =>
navigation &&
navigation.addListener('blur', () =>
emitter.emit({ type: 'blur', target: currentFocusedKey })
),
[currentFocusedKey, emitter, navigation]
);
React.useEffect(() => {
const lastFocusedKey = lastFocusedKeyRef.current;
lastFocusedKeyRef.current = currentFocusedKey;
// We wouldn't have `lastFocusedKey` on initial mount
// Fire focus event for the current route on mount if there's no parent navigator
if (lastFocusedKey === undefined && !navigation) {
emitter.emit({ type: 'focus', target: currentFocusedKey });
}
// We should only dispatch events when the focused key changed and navigator is focused
// When navigator is not focused, screens inside shouldn't receive focused status either
if (
lastFocusedKey === currentFocusedKey ||
!(navigation ? navigation.isFocused() : true)
) {
return;
}
state.routes.forEach((route, i) => {
if (
lastFocusedKey === undefined ||
(route.key !== lastFocusedKey && route.key !== currentFocusedKey)
) {
// Only fire events after mount, or if focus state of this route changed
return;
}
emitter.emit({
type: i === state.index ? 'focus' : 'blur',
target: route.key,
});
});
}, [currentFocusedKey, emitter, navigation, state.index, state.routes]);
}

View File

@@ -1,7 +1,10 @@
import * as React from 'react';
import NavigationContext from './NavigationContext';
import { NavigationProp, ParamListBase } from './types';
export default function useNavigation() {
export default function useNavigation<
T extends NavigationProp<ParamListBase>
>(): T {
const navigation = React.useContext(NavigationContext);
if (navigation === undefined) {
@@ -10,5 +13,5 @@ export default function useNavigation() {
);
}
return navigation;
return navigation as T;
}

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import { NavigationStateContext } from './NavigationContainer';
import Screen from './Screen';
import useEventEmitter from './useEventEmitter';
import useRegisterNavigator from './useRegisterNavigator';
import useDescriptors from './useDescriptors';
import useNavigationHelpers from './useNavigationHelpers';
import useOnAction from './useOnAction';
import useFocusEvents from './useFocusEvents';
import useOnRouteFocus from './useOnRouteFocus';
import useChildActionListeners from './useChildActionListeners';
import {
@@ -155,6 +157,10 @@ export default function useNavigationBuilder<
: (currentState as State);
}, [getCurrentState, initialState]);
const emitter = useEventEmitter();
useFocusEvents({ state, emitter });
const {
listeners: actionListeners,
addActionListener,
@@ -181,6 +187,7 @@ export default function useNavigationBuilder<
onAction,
getState,
setState,
emitter,
actionCreators: router.actionCreators,
});
@@ -194,6 +201,7 @@ export default function useNavigationBuilder<
onRouteFocus,
addActionListener,
removeActionListener,
emitter,
});
return {

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { NavigationEventEmitter } from './useEventEmitter';
import {
NavigationAction,
NavigationHelpers,
@@ -9,30 +10,41 @@ import {
type Options = {
state: NavigationState;
getState: () => NavigationState;
navigation: NavigationHelpers<ParamListBase>;
setOptions: (
cb: (options: { [key: string]: object }) => { [key: string]: object }
) => void;
emitter: NavigationEventEmitter;
};
type NavigationCache = { [key: string]: NavigationProp<ParamListBase> };
export default function useNavigationCache({
state,
getState,
navigation,
setOptions,
emitter,
}: Options) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const cache = React.useMemo(() => ({ current: {} as NavigationCache }), [
getState,
navigation,
setOptions,
emitter,
]);
cache.current = state.routes.reduce<NavigationCache>((acc, route) => {
acc[route.key] =
cache.current[route.key] ||
({
...navigation,
cache.current = state.routes.reduce<NavigationCache>((acc, route, index) => {
if (cache.current[route.key]) {
acc[route.key] = cache.current[route.key];
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { emit, ...rest } = navigation;
acc[route.key] = {
...rest,
...emitter.create(route.key),
dispatch: (
action:
| NavigationAction
@@ -48,7 +60,17 @@ export default function useNavigationCache({
...o,
[route.key]: { ...o[route.key], ...options },
})),
} as NavigationProp<ParamListBase>);
isFocused: () => {
const state = getState();
if (index !== state.index) {
return false;
}
return navigation ? navigation.isFocused() : true;
},
} as NavigationProp<ParamListBase>;
}
return acc;
}, {});

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import * as BaseActions from './BaseActions';
import NavigationContext from './NavigationContext';
import { NavigationStateContext } from './NavigationContainer';
import { NavigationEventEmitter } from './useEventEmitter';
import {
NavigationHelpers,
NavigationAction,
@@ -18,6 +19,7 @@ type Options<Action extends NavigationAction> = {
getState: () => NavigationState;
setState: (state: NavigationState) => void;
actionCreators?: ActionCreators<Action>;
emitter: NavigationEventEmitter;
};
export default function useNavigationHelpers<Action extends NavigationAction>({
@@ -25,6 +27,7 @@ export default function useNavigationHelpers<Action extends NavigationAction>({
getState,
setState,
actionCreators,
emitter,
}: Options<Action>) {
const parentNavigationHelpers = React.useContext(NavigationContext);
const { performTransaction } = React.useContext(NavigationStateContext);
@@ -59,10 +62,15 @@ export default function useNavigationHelpers<Action extends NavigationAction>({
{} as { [key: string]: () => void }
),
dispatch,
emit: emitter.emit,
isFocused: parentNavigationHelpers
? parentNavigationHelpers.isFocused
: () => true,
};
}, [
actionCreators,
parentNavigationHelpers,
emitter.emit,
performTransaction,
setState,
getState,