feat: let the navigator specify if default can be prevented

This commit is contained in:
Satyajit Sahoo
2020-01-14 16:48:56 +01:00
parent ee381a4ba3
commit da67e134d2
13 changed files with 178 additions and 58 deletions

View File

@@ -17,11 +17,11 @@ export type BottomTabNavigationEventMap = {
/**
* Event which fires on tapping on the tab in the tab bar.
*/
tabPress: undefined;
tabPress: { data: undefined; canPreventDefault: true };
/**
* Event which fires on long press on the tab in the tab bar.
*/
tabLongPress: undefined;
tabLongPress: { data: undefined };
};
export type LabelPosition = 'beside-icon' | 'below-icon';

View File

@@ -205,6 +205,7 @@ export default function BottomTabBar({
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) {

View File

@@ -391,6 +391,8 @@ 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].defaultPrevented).toBe(undefined);
expect(thirdCallback.mock.calls[0][0].preventDefault).toBe(undefined);
act(() => {
ref.current.navigation.emit({ type: eventName });
@@ -400,3 +402,62 @@ it('fires custom events', () => {
expect(secondCallback).toBeCalledTimes(1);
expect(thirdCallback).toBeCalledTimes(2);
});
it('has option to prevent default', () => {
expect.assertions(5);
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 = (e: any) => {
expect(e.type).toBe('someSuperCoolEvent');
expect(e.data).toBe(42);
expect(e.defaultPrevented).toBe(false);
expect(e.preventDefault).not.toBe(undefined);
e.preventDefault();
expect(e.defaultPrevented).toBe(true);
};
const Test = ({ 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={Test} />
</TestNavigator>
</NavigationContainer>
);
render(element);
act(() => {
ref.current.navigation.emit({
type: eventName,
data: 42,
canPreventDefault: true,
});
});
});

View File

@@ -207,31 +207,51 @@ export type Router<
export type ParamListBase = Record<string, object | undefined>;
export type EventMapBase = {
focus: undefined;
blur: undefined;
export type EventMapBase = Record<
string,
{ data?: any; canPreventDefault?: boolean }
>;
export type EventMapCore = {
focus: { data: undefined };
blur: { data: undefined };
};
export type EventArg<EventName extends string, Data = undefined> = {
export type EventArg<
EventName extends string,
CanPreventDefault extends boolean | undefined = false,
Data = undefined
> = {
/**
* Type of the event (e.g. `focus`, `blur`)
*/
readonly type: EventName;
/**
* Whether `event.preventDefault()` was called on this event object.
*/
readonly defaultPrevented: boolean;
/**
* Prevent the default action which happens on this event.
*/
preventDefault(): void;
} & (Data extends undefined ? {} : { readonly data: Data });
} & (CanPreventDefault extends true
? {
/**
* Whether `event.preventDefault()` was called on this event object.
*/
readonly defaultPrevented: boolean;
/**
* Prevent the default action which happens on this event.
*/
preventDefault(): void;
}
: {}) &
(Data extends undefined ? {} : { readonly data: Data });
export type EventListenerCallback<EventName extends string, Data> = (
e: EventArg<EventName, Data>
export type EventListenerCallback<
EventMap extends EventMapBase,
EventName extends keyof EventMap
> = (
e: EventArg<
Extract<EventName, string>,
EventMap[EventName]['canPreventDefault'],
EventMap[EventName]['data']
>
) => void;
export type EventConsumer<EventMap extends Record<string, any>> = {
export type EventConsumer<EventMap extends EventMapBase> = {
/**
* Subscribe to events from the parent navigator.
*
@@ -240,15 +260,15 @@ export type EventConsumer<EventMap extends Record<string, any>> = {
*/
addListener<EventName extends Extract<keyof EventMap, string>>(
type: EventName,
callback: EventListenerCallback<EventName, EventMap[EventName]>
callback: EventListenerCallback<EventMap, EventName>
): () => void;
removeListener<EventName extends Extract<keyof EventMap, string>>(
type: EventName,
callback: EventListenerCallback<EventName, EventMap[EventName]>
callback: EventListenerCallback<EventMap, EventName>
): void;
};
export type EventEmitter<EventMap extends Record<string, any>> = {
export type EventEmitter<EventMap extends EventMapBase> = {
/**
* Emit an event to child screens.
*
@@ -257,14 +277,19 @@ export type EventEmitter<EventMap extends Record<string, any>> = {
* @param [options.target] Key of the target route which should receive the event.
* If not specified, all routes receive the event.
*/
emit<EventName extends Extract<keyof EventMap, string>>(
emit<
EventName extends Extract<keyof EventMap, string>,
CanPreventDefault extends EventMap[EventName]['canPreventDefault'],
Data extends EventMap[EventName]['data']
>(
options: {
type: EventName;
target?: string;
} & (EventMap[EventName] extends undefined
? {}
: { data: EventMap[EventName] })
): EventArg<EventName, EventMap[EventName]>;
} & (CanPreventDefault extends true
? { canPreventDefault: CanPreventDefault }
: {}) &
(Data extends undefined ? {} : { data: Data })
): EventArg<EventName, CanPreventDefault, Data>;
};
export class PrivateValueStore<A, B, C> {
@@ -368,7 +393,7 @@ type NavigationHelpersCommon<
export type NavigationHelpers<
ParamList extends ParamListBase,
EventMap extends Record<string, any> = {}
EventMap extends EventMapBase = {}
> = NavigationHelpersCommon<ParamList> &
EventEmitter<EventMap> & {
/**
@@ -408,7 +433,7 @@ export type NavigationProp<
RouteName extends keyof ParamList = string,
State extends NavigationState = NavigationState,
ScreenOptions extends object = {},
EventMap extends Record<string, any> = {}
EventMap extends EventMapBase = {}
> = NavigationHelpersCommon<ParamList, State> & {
/**
* Update the param object for the route.
@@ -439,7 +464,7 @@ export type NavigationProp<
* Note that this method doesn't re-render screen when the result changes. So don't use it in `render`.
*/
dangerouslyGetState(): State;
} & EventConsumer<EventMap & EventMapBase> &
} & EventConsumer<EventMap & EventMapCore> &
PrivateValueStore<ParamList, RouteName, EventMap>;
export type RouteProp<
@@ -493,7 +518,7 @@ export type Descriptor<
RouteName extends keyof ParamList = string,
State extends NavigationState = NavigationState,
ScreenOptions extends object = {},
EventMap extends Record<string, any> = {}
EventMap extends EventMapBase = {}
> = {
/**
* Render the component associated with this route.

View File

@@ -43,7 +43,17 @@ export default function useEventEmitter(): NavigationEventEmitter {
}, []);
const emit = React.useCallback(
({ type, data, target }: { type: string; data?: any; target?: string }) => {
({
type,
data,
target,
canPreventDefault,
}: {
type: string;
data?: any;
target?: string;
canPreventDefault?: boolean;
}) => {
const items = listeners.current[type] || {};
// Copy the current list of callbacks in case they are mutated during execution
@@ -52,26 +62,40 @@ export default function useEventEmitter(): NavigationEventEmitter {
? items[target] && items[target].slice()
: ([] as Listeners).concat(...Object.keys(items).map(t => items[t]));
let defaultPrevented = false;
const event: EventArg<any, any> = {
const event: EventArg<any, any, any> = {
get type() {
return type;
},
get data() {
return data;
},
get defaultPrevented() {
return defaultPrevented;
},
preventDefault() {
defaultPrevented = true;
},
};
if (data !== undefined) {
Object.defineProperty(event, 'data', {
get() {
return data;
},
});
}
if (canPreventDefault) {
let defaultPrevented = false;
Object.defineProperties(event, {
defaultPrevented: {
get() {
return defaultPrevented;
},
},
preventDefault: {
value() {
defaultPrevented = true;
},
},
});
}
callbacks?.forEach(cb => cb(event));
return event;
return event as any;
},
[]
);

View File

@@ -173,11 +173,11 @@ export type DrawerNavigationEventMap = {
/**
* Event which fires when the drawer opens.
*/
drawerOpen: undefined;
drawerOpen: { data: undefined };
/**
* Event which fires when the drawer closes.
*/
drawerClose: undefined;
drawerClose: { data: undefined };
};
export type DrawerNavigationHelpers = NavigationHelpers<

View File

@@ -11,7 +11,7 @@ export type MaterialBottomTabNavigationEventMap = {
/**
* Event which fires on tapping on the tab in the tab bar.
*/
tabPress: undefined;
tabPress: { data: undefined };
};
export type MaterialBottomTabNavigationHelpers = NavigationHelpers<

View File

@@ -13,19 +13,19 @@ export type MaterialTopTabNavigationEventMap = {
/**
* Event which fires on tapping on the tab in the tab bar.
*/
tabPress: undefined;
tabPress: { data: undefined; canPreventDefault: true };
/**
* Event which fires on long press on the tab in the tab bar.
*/
tabLongPress: undefined;
tabLongPress: { data: undefined };
/**
* Event which fires when a swipe gesture starts, i.e. finger touches the screen.
*/
swipeStart: undefined;
swipeStart: { data: undefined };
/**
* Event which fires when a swipe gesture ends, i.e. finger leaves the screen.
*/
swipeEnd: undefined;
swipeEnd: { data: undefined };
};
export type MaterialTopTabNavigationHelpers = NavigationHelpers<

View File

@@ -49,6 +49,7 @@ export default function TabBarTop(props: MaterialTopTabBarProps) {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (event.defaultPrevented) {

View File

@@ -44,13 +44,17 @@ function NativeStackNavigator(props: NativeStackNavigatorProps) {
React.useEffect(
() =>
navigation.addListener &&
navigation.addListener('tabPress', (e: EventArg<'tabPress'>) => {
navigation.addListener('tabPress', e => {
const isFocused = navigation.isFocused();
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
if (state.index > 0 && isFocused && !e.defaultPrevented) {
if (
state.index > 0 &&
isFocused &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
// When user taps on already focused tab and we're inside the tab,
// reset the stack to replicate native behaviour
navigation.dispatch({

View File

@@ -66,7 +66,7 @@ export default function useScrollToTop(
// in addition, there are multiple tab implementations
// @ts-ignore
'tabPress',
(e: EventArg<'tabPress'>) => {
(e: EventArg<'tabPress', true>) => {
// We should scroll to top only when the screen is focused
const isFocused = navigation.isFocused();

View File

@@ -42,13 +42,17 @@ function StackNavigator({
React.useEffect(
() =>
navigation.addListener &&
navigation.addListener('tabPress', (e: EventArg<'tabPress'>) => {
navigation.addListener('tabPress', e => {
const isFocused = navigation.isFocused();
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
if (state.index > 0 && isFocused && !e.defaultPrevented) {
if (
state.index > 0 &&
isFocused &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
// When user taps on already focused tab and we're inside the tab,
// reset the stack to replicate native behaviour
navigation.dispatch({

View File

@@ -20,11 +20,11 @@ export type StackNavigationEventMap = {
/**
* Event which fires when a transition animation starts.
*/
transitionStart: { closing: boolean };
transitionStart: { data: { closing: boolean } };
/**
* Event which fires when a transition animation ends.
*/
transitionEnd: { closing: boolean };
transitionEnd: { data: { closing: boolean } };
};
export type StackNavigationHelpers = NavigationHelpers<