Files
streamyfin/docs/tv-modal-guide.md
Fredrik Burmester 85a74a9a6a refactor: login page
2026-01-31 10:52:21 +01:00

417 lines
12 KiB
Markdown

# TV Modal Guide
This document explains how to implement modals, bottom sheets, and overlays on Apple TV and Android TV in React Native.
## The Problem
On TV platforms, modals have unique challenges:
- The hardware back button must work correctly to dismiss modals
- Focus management must be handled explicitly
- React Native's `Modal` component breaks the TV focus chain
- Overlay/absolute-positioned modals don't handle back button correctly
## Navigation-Based Modal Pattern (Recommended)
For modals that need proper back button support, use the **navigation-based modal pattern**. This leverages Expo Router's stack navigation with transparent modal presentation.
### Architecture
```
┌─────────────────────────────────────┐
│ 1. Jotai Atom (state) │
│ Stores modal data/params │
├─────────────────────────────────────┤
│ 2. Hook (trigger) │
│ Sets atom + calls router.push() │
├─────────────────────────────────────┤
│ 3. Page File (UI) │
│ Reads atom, renders modal │
│ Clears atom on unmount │
├─────────────────────────────────────┤
│ 4. Stack.Screen (config) │
│ presentation: transparentModal │
│ animation: fade │
└─────────────────────────────────────┘
```
### Step 1: Create the Atom
Create a Jotai atom to store the modal state/data:
```typescript
// utils/atoms/tvExampleModal.ts
import { atom } from "jotai";
export interface TVExampleModalData {
itemId: string;
title: string;
// ... other data the modal needs
}
export const tvExampleModalAtom = atom<TVExampleModalData | null>(null);
```
### Step 2: Create the Hook
Create a hook that sets the atom and navigates to the modal:
```typescript
// hooks/useTVExampleModal.ts
import { useSetAtom } from "jotai";
import { router } from "expo-router";
import { tvExampleModalAtom, TVExampleModalData } from "@/utils/atoms/tvExampleModal";
export const useTVExampleModal = () => {
const setModalData = useSetAtom(tvExampleModalAtom);
const openModal = (data: TVExampleModalData) => {
setModalData(data);
router.push("/tv-example-modal");
};
return { openModal };
};
```
### Step 3: Create the Modal Page
Create a page file that reads the atom and renders the modal UI:
```typescript
// app/(auth)/tv-example-modal.tsx
import { useEffect } from "react";
import { View, Pressable, Text } from "react-native";
import { useAtom } from "jotai";
import { router } from "expo-router";
import { BlurView } from "expo-blur";
import { tvExampleModalAtom } from "@/utils/atoms/tvExampleModal";
export default function TVExampleModal() {
const [modalData, setModalData] = useAtom(tvExampleModalAtom);
// Clear atom on unmount
useEffect(() => {
return () => {
setModalData(null);
};
}, [setModalData]);
// Handle case where modal is opened without data
if (!modalData) {
router.back();
return null;
}
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
{/* Background overlay */}
<Pressable
style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
}}
onPress={() => router.back()}
/>
{/* Modal content */}
<BlurView
intensity={80}
tint="dark"
style={{
padding: 32,
borderRadius: 16,
minWidth: 400,
}}
>
<Text style={{ color: "#fff", fontSize: 24 }}>
{modalData.title}
</Text>
{/* Modal content here */}
<Pressable
onPress={() => router.back()}
hasTVPreferredFocus
style={({ focused }) => ({
marginTop: 24,
padding: 16,
borderRadius: 8,
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
})}
>
{({ focused }) => (
<Text style={{ color: focused ? "#000" : "#fff" }}>
Close
</Text>
)}
</Pressable>
</BlurView>
</View>
);
}
```
### Step 4: Add Stack.Screen Configuration
Add the modal route to `app/_layout.tsx`:
```typescript
// In app/_layout.tsx, inside your Stack navigator
<Stack.Screen
name="(auth)/tv-example-modal"
options={{
presentation: "transparentModal",
animation: "fade",
headerShown: false,
}}
/>
```
### Usage
```typescript
// In any component
import { useTVExampleModal } from "@/hooks/useTVExampleModal";
const MyComponent = () => {
const { openModal } = useTVExampleModal();
return (
<Pressable
onPress={() => openModal({ itemId: "123", title: "Example" })}
>
<Text>Open Modal</Text>
</Pressable>
);
};
```
### Reference Implementation
See `useTVRequestModal` + `app/(auth)/tv-request-modal.tsx` for a complete working example.
---
## Bottom Sheet Pattern (Inline Overlays)
For simpler overlays that don't need back button navigation (like option selectors), use an **inline absolute-positioned overlay**. This pattern is ideal for:
- Dropdown selectors
- Quick action menus
- Option pickers
### Key Principles
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain
2. **Horizontal ScrollView for options** - Natural for TV remotes (left/right D-pad)
3. **Disable background focus** - Prevent focus flickering between overlay and background
### Implementation
```typescript
import { useState } from "react";
import { View, ScrollView, Pressable, Text } from "react-native";
import { BlurView } from "expo-blur";
const TVOptionSelector: React.FC<{
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
isOpen: boolean;
onClose: () => void;
}> = ({ options, selectedValue, onSelect, isOpen, onClose }) => {
if (!isOpen) return null;
const selectedIndex = options.findIndex(o => o.value === selectedValue);
return (
<View
style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint="dark"
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingVertical: 32,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={option.value}
label={option.label}
isSelected={option.value === selectedValue}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</BlurView>
</View>
);
};
```
### Option Card Component
```typescript
import { useState, useRef, useEffect } from "react";
import { Pressable, Text, Animated } from "react-native";
const TVOptionCard: React.FC<{
label: string;
isSelected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, isSelected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (toValue: number) => {
Animated.spring(scale, {
toValue,
useNativeDriver: true,
tension: 50,
friction: 7,
}).start();
};
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 12,
borderWidth: isSelected ? 2 : 0,
borderColor: "#fff",
}}
>
<Text style={{ color: focused ? "#000" : "#fff", fontSize: 18 }}>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
```
### Reference Implementation
See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`.
---
## Focus Management for Overlays
**CRITICAL**: When displaying overlays on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop.
### Solution
Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
```typescript
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
### Reference Implementation
See `settings.tv.tsx` for a complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
---
## Focus Trapping
For modals that should trap focus (prevent navigation outside the modal), use `TVFocusGuideView` with trap props:
```typescript
import { TVFocusGuideView } from "react-native";
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
>
{/* Modal content - focus cannot escape */}
</TVFocusGuideView>
```
**Warning**: Don't use `autoFocus` on focus guide wrappers when you also have bidirectional focus guides - it can interfere with navigation.
---
## Common Mistakes
| Mistake | Result | Fix |
|---------|--------|-----|
| Using React Native `Modal` | Focus chain breaks | Use navigation-based or absolute positioning |
| Overlay without disabling background focus | Focus flickering loop | Add `disabled` prop to all background focusables |
| No `hasTVPreferredFocus` in modal | Focus stuck on background | Set preferred focus on first modal element |
| Missing `presentation: "transparentModal"` | Modal not transparent | Add to Stack.Screen options |
| Not clearing atom on unmount | Stale data on reopen | Clear in useEffect cleanup |
---
## When to Use Which Pattern
| Scenario | Pattern |
|----------|---------|
| Full-screen modal with back button | Navigation-based modal |
| Confirmation dialogs | Navigation-based modal |
| Option selectors / dropdowns | Bottom sheet (inline) |
| Quick action menus | Bottom sheet (inline) |
| Complex forms | Navigation-based modal |