From b85007d2682c92e245cf393b7b4dac4d6b2f73ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Leroy?= <133002499+remilry@users.noreply.github.com> Date: Fri, 16 Feb 2024 10:03:35 +0100 Subject: [PATCH] chore(example): add swiping with AppleTV controller (#67) * chore: add emitKeyDown method to RemoteControlManager * chore: allow to swipe with ApppleTV controller * refactor: improve PanEvent using class instead of object --- packages/example/App.tsx | 2 + .../components/PanEvent/PanEvent.constants.ts | 4 ++ .../src/components/PanEvent/PanEvent.ts | 31 +++++++++++ .../src/components/PanEvent/PanEvent.utils.ts | 54 +++++++++++++++++++ .../components/PanEvent/panEventHandler.ts | 21 ++++++++ .../components/PanEvent/useTVPanEvent.ios.ts | 13 +++++ .../src/components/PanEvent/useTVPanEvent.ts | 3 ++ .../RemoteControlManager.android.ts | 4 ++ .../RemoteControlManager.interface.ts | 1 + .../RemoteControlManager.ios.ts | 4 ++ .../remote-control/RemoteControlManager.ts | 4 ++ packages/example/src/utils/repeat.ts | 12 +++++ packages/example/src/utils/throttle.ts | 15 ++++++ 13 files changed, 168 insertions(+) create mode 100644 packages/example/src/components/PanEvent/PanEvent.constants.ts create mode 100644 packages/example/src/components/PanEvent/PanEvent.ts create mode 100644 packages/example/src/components/PanEvent/PanEvent.utils.ts create mode 100644 packages/example/src/components/PanEvent/panEventHandler.ts create mode 100644 packages/example/src/components/PanEvent/useTVPanEvent.ios.ts create mode 100644 packages/example/src/components/PanEvent/useTVPanEvent.ts create mode 100644 packages/example/src/utils/repeat.ts create mode 100644 packages/example/src/utils/throttle.ts diff --git a/packages/example/App.tsx b/packages/example/App.tsx index 00b203df..a14068fa 100644 --- a/packages/example/App.tsx +++ b/packages/example/App.tsx @@ -15,6 +15,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { ProgramDetail } from './src/pages/ProgramDetail'; import { NonVirtualizedGridPage } from './src/pages/NonVirtualizedGridPage'; import { GridWithLongNodesPage } from './src/pages/GridWithLongNodesPage'; +import { useTVPanEvent } from './src/components/PanEvent/useTVPanEvent.ios'; const Stack = createNativeStackNavigator(); @@ -58,6 +59,7 @@ const TabNavigator = () => { }; function App(): JSX.Element { + useTVPanEvent(); const { height, width } = useWindowDimensions(); const areFontsLoaded = useFonts(); diff --git a/packages/example/src/components/PanEvent/PanEvent.constants.ts b/packages/example/src/components/PanEvent/PanEvent.constants.ts new file mode 100644 index 00000000..2a6c4507 --- /dev/null +++ b/packages/example/src/components/PanEvent/PanEvent.constants.ts @@ -0,0 +1,4 @@ +export const GRID_SIZE = 1920; +export const NUMBER_OF_COLUMNS = 5; +export const EMIT_KEY_DOWN_INTERVAL = 30; +export const THROTTLE_DELAY_MS = 30; diff --git a/packages/example/src/components/PanEvent/PanEvent.ts b/packages/example/src/components/PanEvent/PanEvent.ts new file mode 100644 index 00000000..db2a4cdd --- /dev/null +++ b/packages/example/src/components/PanEvent/PanEvent.ts @@ -0,0 +1,31 @@ +import { getGridCoordinates, moveFocus } from './PanEvent.utils'; + +class PanEvent { + private orientation = undefined; + private lastIndex = 0; + + reset = () => { + this.orientation = undefined; + this.lastIndex = 0; + }; + handlePanEvent = ({ x, y }: { x: number; y: number }) => { + const newIndex = getGridCoordinates(x, y, this); + if (!newIndex) return; + moveFocus(newIndex, this); + }; + + getOrientation = () => { + return this.orientation; + }; + setOrientation = (orientation: string) => { + this.orientation = orientation; + }; + getLastIndex = () => { + return this.lastIndex; + }; + setLastIndex = (lastIndex: number) => { + this.lastIndex = lastIndex; + }; +} + +export default PanEvent; diff --git a/packages/example/src/components/PanEvent/PanEvent.utils.ts b/packages/example/src/components/PanEvent/PanEvent.utils.ts new file mode 100644 index 00000000..b40325ab --- /dev/null +++ b/packages/example/src/components/PanEvent/PanEvent.utils.ts @@ -0,0 +1,54 @@ +import { repeat } from '../../utils/repeat'; +import RemoteControlManager from '../remote-control/RemoteControlManager'; +import { SupportedKeys } from '../remote-control/SupportedKeys'; +import PanEvent from './PanEvent'; +import { EMIT_KEY_DOWN_INTERVAL, GRID_SIZE, NUMBER_OF_COLUMNS } from './PanEvent.constants'; + +export const getGridCoordinates = (x: number, y: number, panEvent: PanEvent): number => { + const gridElementSize = GRID_SIZE / NUMBER_OF_COLUMNS; + + const xIndex = Math.floor((x + gridElementSize / 2) / gridElementSize); + const yIndex = Math.floor((y + gridElementSize / 2) / gridElementSize); + + if (!panEvent.getOrientation()) { + // Lock orientation after significant movement to avoid sliding in two directions + if (xIndex !== panEvent.getLastIndex()) { + panEvent.setOrientation('x'); + return xIndex; + } + if (yIndex !== panEvent.getLastIndex()) { + panEvent.setOrientation('y'); + return yIndex; + } + return; + } + + if (panEvent.getOrientation() === 'x' && xIndex !== panEvent.getLastIndex()) { + return xIndex; + } + + if (panEvent.getOrientation() === 'y' && yIndex !== panEvent.getLastIndex()) { + return yIndex; + } +}; + +export const moveFocus = (index: number, panEvent: PanEvent) => { + const indexDif = index - panEvent.getLastIndex(); + panEvent.setLastIndex(index); + + if (panEvent.getOrientation() === 'x') { + repeat( + () => + RemoteControlManager.emitKeyDown(indexDif > 0 ? SupportedKeys.Right : SupportedKeys.Left), + EMIT_KEY_DOWN_INTERVAL, + Math.abs(indexDif), + ); + } + if (panEvent.getOrientation() === 'y') { + repeat( + () => RemoteControlManager.emitKeyDown(indexDif > 0 ? SupportedKeys.Down : SupportedKeys.Up), + EMIT_KEY_DOWN_INTERVAL, + Math.abs(indexDif), + ); + } +}; diff --git a/packages/example/src/components/PanEvent/panEventHandler.ts b/packages/example/src/components/PanEvent/panEventHandler.ts new file mode 100644 index 00000000..bfa9bdd7 --- /dev/null +++ b/packages/example/src/components/PanEvent/panEventHandler.ts @@ -0,0 +1,21 @@ +import { HWEvent } from 'react-native'; +import { throttle } from '../../utils/throttle'; + +import PanEvent from './PanEvent'; +import { THROTTLE_DELAY_MS } from './PanEvent.constants'; + +const myPanEvent = new PanEvent(); + +export const panEventHandler = (event: HWEvent) => { + throttle(() => { + if (event.eventType === 'pan') { + if (!event.body) return; + if (event.body.state === 'Began') { + myPanEvent.reset(); + } + if (event.body.state === 'Changed') { + myPanEvent.handlePanEvent({ x: event.body.x, y: event.body.y }); + } + } + }, THROTTLE_DELAY_MS)(); +}; diff --git a/packages/example/src/components/PanEvent/useTVPanEvent.ios.ts b/packages/example/src/components/PanEvent/useTVPanEvent.ios.ts new file mode 100644 index 00000000..7af77944 --- /dev/null +++ b/packages/example/src/components/PanEvent/useTVPanEvent.ios.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { TVEventControl, useTVEventHandler } from 'react-native'; +import { panEventHandler } from './panEventHandler'; + +export const useTVPanEvent = () => { + useEffect(() => { + TVEventControl.enableTVPanGesture(); + return () => { + TVEventControl.disableTVPanGesture(); + }; + }, []); + useTVEventHandler(panEventHandler); +}; diff --git a/packages/example/src/components/PanEvent/useTVPanEvent.ts b/packages/example/src/components/PanEvent/useTVPanEvent.ts new file mode 100644 index 00000000..4f6e35ec --- /dev/null +++ b/packages/example/src/components/PanEvent/useTVPanEvent.ts @@ -0,0 +1,3 @@ +export const useTVPanEvent = () => { + return null; +}; diff --git a/packages/example/src/components/remote-control/RemoteControlManager.android.ts b/packages/example/src/components/remote-control/RemoteControlManager.android.ts index 4b2e050e..553153b1 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.android.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.android.ts @@ -36,6 +36,10 @@ class RemoteControlManager implements RemoteControlManagerInterface { removeKeydownListener = (listener: (event: SupportedKeys) => void) => { this.eventEmitter.off('keyDown', listener); }; + + emitKeyDown = (key: SupportedKeys) => { + this.eventEmitter.emit('keyDown', key); + }; } export default new RemoteControlManager(); diff --git a/packages/example/src/components/remote-control/RemoteControlManager.interface.ts b/packages/example/src/components/remote-control/RemoteControlManager.interface.ts index b61bde7e..91979ea5 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.interface.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.interface.ts @@ -3,4 +3,5 @@ import { SupportedKeys } from './SupportedKeys'; export interface RemoteControlManagerInterface { addKeydownListener: (listener: (event: SupportedKeys) => void) => void; removeKeydownListener: (listener: (event: SupportedKeys) => void) => 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 58714c9e..7e8218a2 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.ios.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.ios.ts @@ -37,6 +37,10 @@ class RemoteControlManager implements RemoteControlManagerInterface { removeKeydownListener = (listener: (event: SupportedKeys) => void) => { this.eventEmitter.off('keyDown', listener); }; + + emitKeyDown = (key: SupportedKeys) => { + this.eventEmitter.emit('keyDown', key); + }; } export default new RemoteControlManager(); diff --git a/packages/example/src/components/remote-control/RemoteControlManager.ts b/packages/example/src/components/remote-control/RemoteControlManager.ts index 61c75708..453aa9c8 100644 --- a/packages/example/src/components/remote-control/RemoteControlManager.ts +++ b/packages/example/src/components/remote-control/RemoteControlManager.ts @@ -34,6 +34,10 @@ class RemoteControlManager implements RemoteControlManagerInterface { removeKeydownListener = (listener: (event: SupportedKeys) => void) => { this.eventEmitter.off('keyDown', listener); }; + + emitKeyDown = (key: SupportedKeys) => { + this.eventEmitter.emit('keyDown', key); + }; } export default new RemoteControlManager(); diff --git a/packages/example/src/utils/repeat.ts b/packages/example/src/utils/repeat.ts new file mode 100644 index 00000000..b0b4f4d0 --- /dev/null +++ b/packages/example/src/utils/repeat.ts @@ -0,0 +1,12 @@ +export const repeat = (callback: () => void, delay: number, repetitions: number) => { + let repeatsLeft = repetitions; + + const interval = setInterval(() => { + if (repeatsLeft === 0) { + clearInterval(interval); + return; + } + callback(); + repeatsLeft--; + }, delay); +}; diff --git a/packages/example/src/utils/throttle.ts b/packages/example/src/utils/throttle.ts new file mode 100644 index 00000000..729dde7f --- /dev/null +++ b/packages/example/src/utils/throttle.ts @@ -0,0 +1,15 @@ +export const throttle = (callback, delay) => { + let wait = false; + + return (...args) => { + if (wait) { + return; + } + + callback(...args); + wait = true; + setTimeout(() => { + wait = false; + }, delay); + }; +};