diff --git a/packages/example/App.tsx b/packages/example/App.tsx index 46982270..e9ae33ef 100644 --- a/packages/example/App.tsx +++ b/packages/example/App.tsx @@ -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'; @@ -78,8 +77,6 @@ function App(): JSX.Element { - - { 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 <>; }; diff --git a/packages/example/src/components/Page.tsx b/packages/example/src/components/Page.tsx index 895511ea..dd6c5164 100644 --- a/packages/example/src/components/Page.tsx +++ b/packages/example/src/components/Page.tsx @@ -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 }; @@ -51,6 +52,7 @@ export const Page = ({ children }: Props) => { isActive={isActive} onDirectionHandledWithoutMovement={onDirectionHandledWithoutMovement} > + {children} diff --git a/packages/example/src/components/configureRemoteControl.ts b/packages/example/src/components/configureRemoteControl.ts index 3c0149b3..b953a8f5 100644 --- a/packages/example/src/components/configureRemoteControl.ts +++ b/packages/example/src/components/configureRemoteControl.ts @@ -15,6 +15,7 @@ SpatialNavigation.configureRemoteControl({ const remoteControlListener = (keyEvent: SupportedKeys) => { callback(mapping[keyEvent]); + return false; }; return RemoteControlManager.addKeydownListener(remoteControlListener); diff --git a/packages/example/src/components/modals/SpatialNavigationOverlay/useLockOverlay.tsx b/packages/example/src/components/modals/SpatialNavigationOverlay/useLockOverlay.tsx index ae58104d..e0b2ca76 100644 --- a/packages/example/src/components/modals/SpatialNavigationOverlay/useLockOverlay.tsx +++ b/packages/example/src/components/modals/SpatialNavigationOverlay/useLockOverlay.tsx @@ -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; @@ -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); }; diff --git a/packages/example/src/components/remote-control/CustomEventEmitter.ts b/packages/example/src/components/remote-control/CustomEventEmitter.ts new file mode 100644 index 00000000..5aec4d7f --- /dev/null +++ b/packages/example/src/components/remote-control/CustomEventEmitter.ts @@ -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 = (event: T) => boolean; + +// An array of all currently registered event handlers for a type +export type EventHandlerList = Array>; + +// A map of event types and their corresponding event handlers. +export type EventHandlerMap> = Map< + keyof Events, + EventHandlerList +>; + +export default class CustomEventEmitter> { + private handlers: EventHandlerMap = new Map(); + + on = (eventType: Key, handler: Handler) => { + const eventTypeHandlers = this.handlers.get(eventType); + if (!Array.isArray(eventTypeHandlers)) this.handlers.set(eventType, [handler]); + else eventTypeHandlers.push(handler); + }; + + off = (eventType: Key, handler?: Handler) => { + this.handlers.set( + eventType, + this.handlers.get(eventType).filter((h) => h !== handler), + ); + }; + + emit = (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; + } + } + }; +} diff --git a/packages/example/src/components/remote-control/RemoteControlManager.android.ts b/packages/example/src/components/remote-control/RemoteControlManager.android.ts index 553153b1..6e935c13 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.android.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.android.ts @@ -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 = { @@ -19,6 +19,7 @@ class RemoteControlManager implements RemoteControlManagerInterface { 66: SupportedKeys.Enter, 23: SupportedKeys.Enter, 67: SupportedKeys.Back, + 4: SupportedKeys.Back, }[keyEvent.keyCode]; if (!mappedKey) { @@ -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); }; diff --git a/packages/example/src/components/remote-control/RemoteControlManager.interface.ts b/packages/example/src/components/remote-control/RemoteControlManager.interface.ts index 91979ea5..58de6ea9 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.interface.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.interface.ts @@ -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; } diff --git a/packages/example/src/components/remote-control/RemoteControlManager.ios.ts b/packages/example/src/components/remote-control/RemoteControlManager.ios.ts index 6d9ed0a4..c8c0d859 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.ios.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.ios.ts @@ -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; @@ -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); }; diff --git a/packages/example/src/components/remote-control/RemoteControlManager.ts b/packages/example/src/components/remote-control/RemoteControlManager.ts index 453aa9c8..4830b41e 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.ts @@ -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 = { @@ -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); }; diff --git a/packages/example/src/components/tests/helpers/configureTestRemoteControl.ts b/packages/example/src/components/tests/helpers/configureTestRemoteControl.ts index 40b27cda..f6e053b3 100644 --- a/packages/example/src/components/tests/helpers/configureTestRemoteControl.ts +++ b/packages/example/src/components/tests/helpers/configureTestRemoteControl.ts @@ -14,6 +14,7 @@ SpatialNavigation.configureRemoteControl({ const remoteControlListener = (keyEvent: SupportedKeys) => { callback(mapping[keyEvent]); + return false; }; return TestRemoteControlManager.addKeydownListener(remoteControlListener); diff --git a/packages/example/src/components/tests/helpers/testRemoteControlManager.ts b/packages/example/src/components/tests/helpers/testRemoteControlManager.ts index d5d2f866..31976e5d 100644 --- a/packages/example/src/components/tests/helpers/testRemoteControlManager.ts +++ b/packages/example/src/components/tests/helpers/testRemoteControlManager.ts @@ -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); }; } diff --git a/packages/example/src/hooks/useKey.ts b/packages/example/src/hooks/useKey.ts new file mode 100644 index 00000000..0c0ba780 --- /dev/null +++ b/packages/example/src/hooks/useKey.ts @@ -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]); +}; diff --git a/packages/example/src/modules/program/view/ProgramList.test.tsx b/packages/example/src/modules/program/view/ProgramList.test.tsx index b2e93877..9a6299e2 100644 --- a/packages/example/src/modules/program/view/ProgramList.test.tsx +++ b/packages/example/src/modules/program/view/ProgramList.test.tsx @@ -24,14 +24,14 @@ describe('ProgramList', () => { jest.spyOn(programInfos, 'getPrograms').mockReturnValue(programsFixture); it('renders the list with every items', () => { - const screen = renderWithProviders(); + const screen = renderWithProviders(); screen.getByLabelText('Program 1'); screen.getByLabelText('Program 2'); }); it('renders the list and focus elements accordingly with inputs', () => { - const screen = renderWithProviders(); + const screen = renderWithProviders(); const program1 = screen.getByLabelText('Program 1'); expect(program1).toBeSelected(); diff --git a/packages/example/src/modules/program/view/ProgramList.tsx b/packages/example/src/modules/program/view/ProgramList.tsx index c900034e..d4c0e401 100644 --- a/packages/example/src/modules/program/view/ProgramList.tsx +++ b/packages/example/src/modules/program/view/ProgramList.tsx @@ -1,8 +1,8 @@ import styled from '@emotion/native'; import { useTheme } from '@emotion/react'; -import { useNavigation } from '@react-navigation/native'; +import { useIsFocused, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { MutableRefObject, useCallback, useMemo } from 'react'; +import { MutableRefObject, useCallback, useMemo, useRef } from 'react'; import { SpatialNavigationNode, SpatialNavigationVirtualizedList, @@ -14,8 +14,11 @@ import { getPrograms } from '../infra/programInfos'; import { ProgramNode } from './ProgramNode'; import { scaledPixels } from '../../../design-system/helpers/scaledPixels'; import { LeftArrow, RightArrow } from '../../../design-system/components/Arrows'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { theme } from '../../../design-system/theme/theme'; +import { SupportedKeys } from '../../../components/remote-control/SupportedKeys'; +import { useKey } from '../../../hooks/useKey'; +import React from 'react'; const NUMBER_OF_ITEMS_VISIBLE_ON_SCREEN = 7; const WINDOW_SIZE = NUMBER_OF_ITEMS_VISIBLE_ON_SCREEN + 8; @@ -30,97 +33,119 @@ type ProgramListProps = { data?: ProgramInfo[]; listSize?: number; variant?: 'normal' | 'variable-size'; + parentRef?: MutableRefObject; + isActive: boolean; }; const isItemLarge = (item: { id: string }) => { return parseInt(item.id, 10) % 2 === 0; // Arbitrary condition to decide size }; -export const ProgramList = ({ - orientation = 'horizontal', - containerStyle, - listRef, - data, - variant = 'normal', - listSize = 1000, -}: ProgramListProps) => { - const navigation = useNavigation>(); - const theme = useTheme(); +export const ProgramList = React.forwardRef( + ({ orientation, containerStyle, data, parentRef, isActive, variant, listSize = 1000 }, ref) => { + const navigation = useNavigation>(); + const theme = useTheme(); + const listRef = useRef(null); - const renderItem = useCallback( - ({ item, index }: { item: ProgramInfo; index: number }) => ( - navigation.push('ProgramDetail', { programInfo: item })} - label={index.toString()} - variant={variant === 'variable-size' && isItemLarge(item) ? 'landscape' : 'portrait'} - /> - ), - [navigation, variant], - ); + const renderItem = useCallback( + ({ item, index }: { item: ProgramInfo; index: number }) => ( + navigation.push('ProgramDetail', { programInfo: item })} + label={index.toString()} + variant={variant === 'variable-size' && isItemLarge(item) ? 'landscape' : 'portrait'} + /> + ), + [navigation, variant], + ); + const isScreenFocused = useIsFocused(); - const programInfos = useMemo(() => data ?? getPrograms(listSize), [data, listSize]); + const programInfos = useMemo(() => data ?? getPrograms(listSize), [data, listSize]); - const itemSize = useMemo( - () => { - if (variant === 'normal') { - return theme.sizes.program.portrait.width + GAP_BETWEEN_ELEMENTS; - } + const itemSize = useMemo( + () => { + if (variant === 'normal') { + return theme.sizes.program.portrait.width + GAP_BETWEEN_ELEMENTS; + } - return (item: ProgramInfo) => - isItemLarge(item) - ? theme.sizes.program.landscape.width + GAP_BETWEEN_ELEMENTS - : theme.sizes.program.portrait.width + GAP_BETWEEN_ELEMENTS; - }, // Default item size for "normal" - [theme.sizes.program.landscape.width, theme.sizes.program.portrait.width, variant], - ); + return (item: ProgramInfo) => + isItemLarge(item) + ? theme.sizes.program.landscape.width + GAP_BETWEEN_ELEMENTS + : theme.sizes.program.portrait.width + GAP_BETWEEN_ELEMENTS; + }, // Default item size for "normal" + [theme.sizes.program.landscape.width, theme.sizes.program.portrait.width, variant], + ); - return ( - - {({ isActive }) => ( - - : null} - descendingArrowContainerStyle={styles.leftArrowContainer} - ascendingArrow={isActive ? : null} - ascendingArrowContainerStyle={styles.rightArrowContainer} - ref={listRef} - /> - - )} - - ); -}; + const goToFirstItem = useCallback( + (pressedKey: SupportedKeys) => { + const isBackKey = pressedKey === SupportedKeys.Back; + const isRowActive = isActive && isScreenFocused; + const isFirstElementFocused = listRef.current.currentlyFocusedItemIndex === 0; + + if (!isBackKey || !isRowActive || isFirstElementFocused) { + return false; + } + + listRef.current.focus(0); + return true; + }, + [isActive, isScreenFocused, listRef], + ); + + useKey(SupportedKeys.Back, goToFirstItem); + + return ( + + : null} + descendingArrowContainerStyle={styles.leftArrowContainer} + ascendingArrow={isActive ? : null} + ascendingArrowContainerStyle={styles.rightArrowContainer} + ref={(elementRef) => { + if (parentRef) parentRef.current = elementRef; + listRef.current = elementRef; + }} + /> + + ); + }, +); +ProgramList.displayName = 'ProgramList'; export const ProgramsRow = ({ containerStyle, - listRef, variant = 'normal', listSize, + parentRef, }: { containerStyle?: object; - listRef?: MutableRefObject; variant?: 'normal' | 'variable-size'; listSize?: number; + parentRef?: MutableRefObject; }) => { const theme = useTheme(); return ( - + + {({ isActive }) => ( + + )} + ); }; diff --git a/packages/example/src/modules/program/view/ProgramListWithTitle.tsx b/packages/example/src/modules/program/view/ProgramListWithTitle.tsx index 4eca6db0..e1d50b04 100644 --- a/packages/example/src/modules/program/view/ProgramListWithTitle.tsx +++ b/packages/example/src/modules/program/view/ProgramListWithTitle.tsx @@ -7,18 +7,18 @@ import { SpatialNavigationVirtualizedListRef } from '../../../../../lib/src/spat type Props = { title: string; - listRef?: MutableRefObject; listSize?: number; + parentRef?: MutableRefObject; }; -export const ProgramListWithTitle = ({ title, listRef, listSize }: Props) => { +export const ProgramListWithTitle = ({ title, parentRef, listSize }: Props) => { return ( {title} - + ); }; diff --git a/packages/example/src/pages/GridWithLongNodesPage.tsx b/packages/example/src/pages/GridWithLongNodesPage.tsx index 16c9d6c2..8ba7090b 100644 --- a/packages/example/src/pages/GridWithLongNodesPage.tsx +++ b/packages/example/src/pages/GridWithLongNodesPage.tsx @@ -25,7 +25,7 @@ const HEADER_SIZE = scaledPixels(400); export const GridWithLongNodesPage = () => { const firstItemRef = useRef(null); const lastItemRef = useRef(null); - const listRef = useRef(null); + const parentRef = useRef(null); return ( @@ -48,18 +48,21 @@ export const GridWithLongNodesPage = () => { - +