fix: handle TV menu and back navigation (#1559)

This commit is contained in:
Steve Byatt
2026-05-20 08:57:19 +01:00
committed by GitHub
parent 92deba14f3
commit ca4f24ded0
6 changed files with 123 additions and 189 deletions

View File

@@ -1,25 +1,12 @@
import { useNavigation } from "@react-navigation/native";
import { router, useSegments } from "expo-router";
import { useEffect, useRef } from "react";
import { BackHandler, Platform } from "react-native";
import { useSegments } from "expo-router";
import { useEffect } from "react";
import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
} from "./useTVBackPress";
// TV event handler and control with fallback for non-TV platforms
let useTVEventHandler: (callback: (evt: any) => void) => void;
let TVEventControl: {
enableTVMenuKey: () => void;
disableTVMenuKey: () => void;
} | null = null;
if (Platform.isTV) {
try {
useTVEventHandler = require("react-native").useTVEventHandler;
TVEventControl = require("react-native").TVEventControl;
} catch {
useTVEventHandler = () => {};
}
} else {
useTVEventHandler = () => {};
}
export { enableTVMenuKeyInterception } from "./useTVBackPress";
/**
* Check if we're at the root of a tab
@@ -55,106 +42,26 @@ function getCurrentTab(segments: string[]): string | undefined {
}
/**
* Hook to handle TV back/menu button presses.
*
* Behavior:
* - On home tab at root: allows app to exit (default tvOS behavior)
* - On other tabs at root: navigates to home tab
* - Deeper in navigation stack: goes back
* Keeps tvOS menu key interception disabled on the home tab root so the system
* can apply its native app-exit behavior. Other routes can opt into
* interception when they need JS-owned back handling.
*/
export function useTVBackHandler() {
const navigation = useNavigation<any>();
export function useTVHomeBackHandler() {
const segments = useSegments();
const lastMenuKeyState = useRef<boolean | null>(null);
// Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
// Toggle menu key interception based on current location
useEffect(() => {
if (!Platform.isTV || !TVEventControl) return;
if (isOnHomeRoot) {
// On home tab root - disable interception to allow app exit
if (lastMenuKeyState.current !== false) {
TVEventControl.disableTVMenuKey();
lastMenuKeyState.current = false;
}
} else {
// On other screens - enable interception to handle navigation
if (lastMenuKeyState.current !== true) {
TVEventControl.enableTVMenuKey();
lastMenuKeyState.current = true;
}
}
}, [isOnHomeRoot]);
// Handle TV remote menu/back button events
useTVEventHandler((evt) => {
if (!evt) return;
if (evt.eventType === "menu" || evt.eventType === "back") {
// If on home root, let the default behavior happen (app exit)
if (isOnHomeRoot) {
return;
}
// If at tab root level (but not home), navigate to home
if (atTabRoot) {
router.navigate("/(auth)/(tabs)/(home)");
return;
}
// Not at tab root - go back in the stack
if (navigation.canGoBack()) {
navigation.goBack();
return;
}
// Fallback: navigate to home
router.navigate("/(auth)/(tabs)/(home)");
}
});
// Android TV BackHandler
useEffect(() => {
if (!Platform.isTV) return;
const handleBackPress = () => {
// If on home root, allow app to exit
if (isOnHomeRoot) {
return false; // Don't prevent default (allows exit)
}
if (isOnHomeRoot) {
disableTVMenuKeyInterception();
return;
}
if (atTabRoot) {
router.navigate("/(auth)/(tabs)/(home)");
return true;
}
if (navigation.canGoBack()) {
navigation.goBack();
return true;
}
router.navigate("/(auth)/(tabs)/(home)");
return true;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [navigation, isOnHomeRoot, atTabRoot]);
}
/**
* Call this at app startup to enable TV menu key interception.
*/
export function enableTVMenuKeyInterception() {
if (Platform.isTV && TVEventControl) {
TVEventControl.enableTVMenuKey();
}
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}

57
hooks/useTVBackPress.ts Normal file
View File

@@ -0,0 +1,57 @@
import { type DependencyList, useEffect } from "react";
import { BackHandler, Platform } from "react-native";
type TVBackPressHandler = () => boolean | null | undefined;
let TVEventControl: {
enableTVMenuKey: () => void;
disableTVMenuKey: () => void;
} | null = null;
if (Platform.isTV) {
try {
TVEventControl = require("react-native").TVEventControl;
} catch {
TVEventControl = null;
}
}
export function enableTVMenuKeyInterception() {
if (Platform.isTV && TVEventControl) {
TVEventControl.enableTVMenuKey();
}
}
export function disableTVMenuKeyInterception() {
if (Platform.isTV && TVEventControl) {
TVEventControl.disableTVMenuKey();
}
}
/**
* Subscribe to TV back presses through React Native's BackHandler.
*
* On Android TV this handles the hardware back button. On tvOS,
* react-native-tvos maps the Apple TV menu button to the same API when menu key
* interception is enabled.
*
* @see https://reactnative.dev/docs/backhandler
*/
export function useTVBackPress(
handler: TVBackPressHandler,
deps: DependencyList,
) {
useEffect(() => {
if (!Platform.isTV) return;
// BackHandler is the shared back/menu surface for TV platforms:
// Android TV sends hardware back here, and react-native-tvos sends menu
// here when menu key interception is enabled.
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handler,
);
return () => subscription.remove();
}, deps);
}

View File

@@ -0,0 +1,17 @@
import type { HWEvent } from "react-native";
import { Platform } from "react-native";
type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void;
let tvEventHandler: UseTVEventHandler = () => {};
if (Platform.isTV) {
try {
tvEventHandler = require("react-native")
.useTVEventHandler as UseTVEventHandler;
} catch {
tvEventHandler = () => {};
}
}
export const useTVEventHandler = tvEventHandler;