Skip to content

Commit

Permalink
chore(example): reset to beginning of the list when pressing back (#85)
Browse files Browse the repository at this point in the history
* feat(lists): expose currently focused item index in ref

Co-authored-by: JulienIzz <[email protected]>

* chore(example): add example back button usage for lists

* chore(example): add missing forwardRef

* chore(example): add missing useEffect

* chore(example): more GoBackConfiguration so that each page has its own

Otherwise we can't catch hardware back press properly

---------

Co-authored-by: JulienIzz <[email protected]>
Co-authored-by: JulienIzz <[email protected]>
Co-authored-by: Pierre Poupin <[email protected]>
  • Loading branch information
4 people authored Sep 13, 2024
1 parent 4d55b25 commit aeb548e
Show file tree
Hide file tree
Showing 19 changed files with 229 additions and 121 deletions.
3 changes: 0 additions & 3 deletions packages/example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ThemeProvider } from '@emotion/react';
import { NavigationContainer } from '@react-navigation/native';
import { useWindowDimensions } from 'react-native';
import { GoBackConfiguration } from './src/components/GoBackConfiguration';
import { theme } from './src/design-system/theme/theme';
import { Home } from './src/pages/Home';
import { ProgramGridPage } from './src/pages/ProgramGridPage';
Expand Down Expand Up @@ -78,8 +77,6 @@ function App(): JSX.Element {
<NavigationContainer>
<ThemeProvider theme={theme}>
<SpatialNavigationDeviceTypeProvider>
<GoBackConfiguration />

<Container width={width} height={height}>
<Stack.Navigator
screenOptions={{
Expand Down
32 changes: 24 additions & 8 deletions packages/example/src/components/GoBackConfiguration.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import { useNavigation } from '@react-navigation/native';
import { useEffect } from 'react';
import RemoteControlManager from './remote-control/RemoteControlManager';
import { SupportedKeys } from './remote-control/SupportedKeys';
import { useKey } from '../hooks/useKey';
import { useCallback, useEffect } from 'react';
import { BackHandler } from 'react-native';

export const GoBackConfiguration = () => {
const navigation = useNavigation();

useEffect(() => {
const remoteControlListener = (pressedKey: SupportedKeys) => {
if (pressedKey !== SupportedKeys.Back) return;
const event = BackHandler.addEventListener('hardwareBackPress', () => {
return true;
});

return () => {
event.remove();
};
}, []);

const goBackOnBackPress = useCallback(
(pressedKey: SupportedKeys) => {
if (!navigation.isFocused) {
return false;
}
if (pressedKey !== SupportedKeys.Back) return false;
if (navigation.canGoBack()) {
navigation.goBack();
return true;
}
};
RemoteControlManager.addKeydownListener(remoteControlListener);
return false;
},
[navigation],
);

return () => RemoteControlManager.removeKeydownListener(remoteControlListener);
}, [navigation]);
useKey(SupportedKeys.Back, goBackOnBackPress);

return <></>;
};
2 changes: 2 additions & 0 deletions packages/example/src/components/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ReactNode, useCallback, useEffect } from 'react';
import { SpatialNavigationRoot, useLockSpatialNavigation } from 'react-tv-space-navigation';
import { useMenuContext } from './Menu/MenuContext';
import { Keyboard } from 'react-native';
import { GoBackConfiguration } from './GoBackConfiguration';

type Props = { children: ReactNode };

Expand Down Expand Up @@ -51,6 +52,7 @@ export const Page = ({ children }: Props) => {
isActive={isActive}
onDirectionHandledWithoutMovement={onDirectionHandledWithoutMovement}
>
<GoBackConfiguration />
<SpatialNavigationKeyboardLocker />
{children}
</SpatialNavigationRoot>
Expand Down
1 change: 1 addition & 0 deletions packages/example/src/components/configureRemoteControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ SpatialNavigation.configureRemoteControl({

const remoteControlListener = (keyEvent: SupportedKeys) => {
callback(mapping[keyEvent]);
return false;
};

return RemoteControlManager.addKeydownListener(remoteControlListener);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useLockSpatialNavigation } from '../../../../../lib/src/spatial-navigation/context/LockSpatialNavigationContext';
import { EventArg, useNavigation } from '@react-navigation/native';
import { useKey } from '../../../hooks/useKey';
import { SupportedKeys } from '../../remote-control/SupportedKeys';

interface UseLockProps {
isModalVisible: boolean;
Expand All @@ -27,17 +28,12 @@ const useLockParentSpatialNavigator = (isModalVisible: boolean) => {
};

const usePreventNavigationGoBack = (isModalVisible: boolean, hideModal: () => void) => {
const navigation = useNavigation();
useEffect(() => {
const hideModalListener = useCallback(() => {
if (isModalVisible) {
const navigationListener = (e: EventArg<'beforeRemove', true>) => {
e.preventDefault();
hideModal();
};
navigation.addListener('beforeRemove', navigationListener);
return () => {
navigation.removeListener('beforeRemove', navigationListener);
};
hideModal();
return true;
}
}, [navigation, isModalVisible, hideModal]);
return false;
}, [isModalVisible, hideModal]);
useKey(SupportedKeys.Back, hideModalListener);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* This event emitter is a minimal reimplementation of `mitt` with the support of stoppable event propagation
*/

export type EventType = string | symbol;

// An event handler can take an optional event argument
// and should return a boolean indicating whether or not to stop event propagation
export type Handler<T = unknown> = (event: T) => boolean;

// An array of all currently registered event handlers for a type
export type EventHandlerList<T = unknown> = Array<Handler<T>>;

// A map of event types and their corresponding event handlers.
export type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<
keyof Events,
EventHandlerList<Events[keyof Events]>
>;

export default class CustomEventEmitter<Events extends Record<EventType, unknown>> {
private handlers: EventHandlerMap<Events> = new Map();

on = <Key extends keyof Events>(eventType: Key, handler: Handler<Events[keyof Events]>) => {
const eventTypeHandlers = this.handlers.get(eventType);
if (!Array.isArray(eventTypeHandlers)) this.handlers.set(eventType, [handler]);
else eventTypeHandlers.push(handler);
};

off = <Key extends keyof Events>(eventType: Key, handler?: Handler<Events[keyof Events]>) => {
this.handlers.set(
eventType,
this.handlers.get(eventType).filter((h) => h !== handler),
);
};

emit = <Key extends keyof Events>(eventType: Key, evt?: Events[Key]) => {
const eventTypeHandlers = this.handlers.get(eventType);
for (let index = eventTypeHandlers.length - 1; index >= 0; index--) {
const handler = eventTypeHandlers[index];
if (handler(evt)) {
return;
}
}
};
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import mitt from 'mitt';
import { SupportedKeys } from './SupportedKeys';
import KeyEvent from 'react-native-keyevent';
import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
import CustomEventEmitter from './CustomEventEmitter';

class RemoteControlManager implements RemoteControlManagerInterface {
constructor() {
KeyEvent.onKeyDownListener(this.handleKeyDown);
}

private eventEmitter = mitt<{ keyDown: SupportedKeys }>();
private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();

private handleKeyDown = (keyEvent: { keyCode: number }) => {
const mappedKey = {
Expand All @@ -19,6 +19,7 @@ class RemoteControlManager implements RemoteControlManagerInterface {
66: SupportedKeys.Enter,
23: SupportedKeys.Enter,
67: SupportedKeys.Back,
4: SupportedKeys.Back,
}[keyEvent.keyCode];

if (!mappedKey) {
Expand All @@ -28,12 +29,12 @@ class RemoteControlManager implements RemoteControlManagerInterface {
this.eventEmitter.emit('keyDown', mappedKey);
};

addKeydownListener = (listener: (event: SupportedKeys) => void) => {
addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.on('keyDown', listener);
return listener;
};

removeKeydownListener = (listener: (event: SupportedKeys) => void) => {
removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.off('keyDown', listener);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SupportedKeys } from './SupportedKeys';

export interface RemoteControlManagerInterface {
addKeydownListener: (listener: (event: SupportedKeys) => void) => void;
removeKeydownListener: (listener: (event: SupportedKeys) => void) => void;
addKeydownListener: (listener: (event: SupportedKeys) => boolean) => void;
removeKeydownListener: (listener: (event: SupportedKeys) => boolean) => void;
emitKeyDown: (key: SupportedKeys) => void;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import mitt from 'mitt';
import { SupportedKeys } from './SupportedKeys';
import { HWEvent, TVEventHandler } from 'react-native';
import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
import CustomEventEmitter from './CustomEventEmitter';

class RemoteControlManager implements RemoteControlManagerInterface {
constructor() {
TVEventHandler.addListener(this.handleKeyDown);
}

private eventEmitter = mitt<{ keyDown: SupportedKeys }>();
private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();

private handleKeyDown = (evt: HWEvent) => {
if (!evt) return;
Expand All @@ -28,12 +28,12 @@ class RemoteControlManager implements RemoteControlManagerInterface {
this.eventEmitter.emit('keyDown', mappedKey);
};

addKeydownListener = (listener: (event: SupportedKeys) => void) => {
addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.on('keyDown', listener);
return listener;
};

removeKeydownListener = (listener: (event: SupportedKeys) => void) => {
removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.off('keyDown', listener);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import mitt from 'mitt';
import { SupportedKeys } from './SupportedKeys';
import { RemoteControlManagerInterface } from './RemoteControlManager.interface';
import CustomEventEmitter from './CustomEventEmitter';

class RemoteControlManager implements RemoteControlManagerInterface {
constructor() {
window.addEventListener('keydown', this.handleKeyDown);
}

private eventEmitter = mitt<{ keyDown: SupportedKeys }>();
private eventEmitter = new CustomEventEmitter<{ keyDown: SupportedKeys }>();

private handleKeyDown = (event: KeyboardEvent) => {
const mappedKey = {
Expand All @@ -26,12 +26,12 @@ class RemoteControlManager implements RemoteControlManagerInterface {
this.eventEmitter.emit('keyDown', mappedKey);
};

addKeydownListener = (listener: (event: SupportedKeys) => void) => {
addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.on('keyDown', listener);
return listener;
};

removeKeydownListener = (listener: (event: SupportedKeys) => void) => {
removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.off('keyDown', listener);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ SpatialNavigation.configureRemoteControl({

const remoteControlListener = (keyEvent: SupportedKeys) => {
callback(mapping[keyEvent]);
return false;
};

return TestRemoteControlManager.addKeydownListener(remoteControlListener);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ class TestRemoteControlManager {
act(() => jest.runAllTimers());
};

addKeydownListener = (listener: (event: SupportedKeys) => void) => {
addKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.on('keyDown', listener);
return listener;
};

removeKeydownListener = (listener: (event: SupportedKeys) => void) => {
removeKeydownListener = (listener: (event: SupportedKeys) => boolean) => {
this.eventEmitter.off('keyDown', listener);
};
}
Expand Down
19 changes: 19 additions & 0 deletions packages/example/src/hooks/useKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import RemoteControlManager from '../components/remote-control/RemoteControlManager';
import { SupportedKeys } from '../components/remote-control/SupportedKeys';

/**
* A convenient hook to listen to a key and react to it
*
* @example useKey(SupportedKeys.Back, () => { console.log('pressed back!') })
*/
export const useKey = (key: SupportedKeys, callback: (pressedKey: SupportedKeys) => boolean) => {
useEffect(() => {
const remoteControlListener = (actualKey: SupportedKeys) => {
if (actualKey !== key) return;
return callback(key);
};
RemoteControlManager.addKeydownListener(remoteControlListener);
return () => RemoteControlManager.removeKeydownListener(remoteControlListener);
}, [key, callback]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ describe('ProgramList', () => {
jest.spyOn(programInfos, 'getPrograms').mockReturnValue(programsFixture);

it('renders the list with every items', () => {
const screen = renderWithProviders(<ProgramList />);
const screen = renderWithProviders(<ProgramList isActive={true} />);

screen.getByLabelText('Program 1');
screen.getByLabelText('Program 2');
});

it('renders the list and focus elements accordingly with inputs', () => {
const screen = renderWithProviders(<ProgramList />);
const screen = renderWithProviders(<ProgramList isActive={true} />);

const program1 = screen.getByLabelText('Program 1');
expect(program1).toBeSelected();
Expand Down
Loading

0 comments on commit aeb548e

Please sign in to comment.