mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-09 19:26:15 +00:00
fix: design
This commit is contained in:
@@ -2,6 +2,54 @@
|
||||
|
||||
This document explains how to use `TVFocusGuideView` to create reliable focus navigation between non-adjacent sections on Apple TV and Android TV.
|
||||
|
||||
## Platform Differences (CRITICAL)
|
||||
|
||||
### tvOS vs Android TV
|
||||
|
||||
**`nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` props only work on Android TV, NOT tvOS.**
|
||||
|
||||
This is a [known limitation](https://github.com/react-native-tvos/react-native-tvos/issues/490). These props are documented as "only for Android" in React Native.
|
||||
|
||||
```typescript
|
||||
// ❌ Does NOT work on tvOS (Apple TV)
|
||||
<Pressable nextFocusUp={someNodeHandle} nextFocusDown={anotherNodeHandle}>
|
||||
...
|
||||
</Pressable>
|
||||
|
||||
// ✅ Works on both tvOS and Android TV
|
||||
<TVFocusGuideView destinations={[targetRef]}>
|
||||
...
|
||||
</TVFocusGuideView>
|
||||
```
|
||||
|
||||
**For tvOS, always use `TVFocusGuideView` with the `destinations` prop.**
|
||||
|
||||
## ScrollView vs FlatList for TV
|
||||
|
||||
**Use ScrollView instead of FlatList for horizontal lists on TV when focus navigation is critical.**
|
||||
|
||||
FlatList only renders visible items and manages its own recycling, which can interfere with focus navigation. ScrollView renders all items at once, providing more predictable focus behavior.
|
||||
|
||||
```typescript
|
||||
// ❌ FlatList can cause focus issues on TV
|
||||
<FlatList
|
||||
horizontal
|
||||
data={cast}
|
||||
renderItem={({ item, index }) => <CastCard ... />}
|
||||
/>
|
||||
|
||||
// ✅ ScrollView provides reliable focus navigation
|
||||
<ScrollView horizontal>
|
||||
{cast.map((person, index) => (
|
||||
<CastCard key={person.id} ... />
|
||||
))}
|
||||
</ScrollView>
|
||||
```
|
||||
|
||||
**When to use which:**
|
||||
- **ScrollView**: Small to medium lists (< 20 items) where focus navigation must be reliable
|
||||
- **FlatList**: Large lists where performance is more important than perfect focus navigation
|
||||
|
||||
## The Problem
|
||||
|
||||
tvOS uses a **geometric focus engine** that draws a ray in the navigation direction and finds the nearest focusable element. This works well for adjacent elements but fails when:
|
||||
@@ -53,159 +101,160 @@ const [targetRef, setTargetRef] = useState<View | null>(null);
|
||||
<TVFocusGuideView destinations={targetRef ? [targetRef] : []} />
|
||||
```
|
||||
|
||||
## Complete Example: Bidirectional Navigation
|
||||
## Bidirectional Navigation (CRITICAL PATTERN)
|
||||
|
||||
This example shows how to create focus navigation between a vertical list of buttons and a horizontal ScrollView of cards.
|
||||
When you need focus to navigate both UP and DOWN between sections, you must stack both focus guides together AND avoid `hasTVPreferredFocus` on the destination element.
|
||||
|
||||
### Step 1: Convert Components to forwardRef
|
||||
### The Focus Flickering Problem
|
||||
|
||||
Any component that needs to be a focus destination must forward its ref:
|
||||
If you use `hasTVPreferredFocus={true}` on an element that is ALSO the destination of a focus guide, you will get **focus flickering** where focus rapidly jumps back and forth between elements.
|
||||
|
||||
```typescript
|
||||
const TVOptionButton = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, onPress }, ref) => {
|
||||
return (
|
||||
<Pressable ref={ref} onPress={onPress}>
|
||||
<Text>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
// ❌ CAUSES FOCUS FLICKERING - destination has hasTVPreferredFocus
|
||||
<TVFocusGuideView destinations={[firstCardRef]} />
|
||||
<ScrollView horizontal>
|
||||
{items.map((item, index) => (
|
||||
<Card
|
||||
ref={index === 0 ? setFirstCardRef : undefined}
|
||||
hasTVPreferredFocus={index === 0} // ❌ DON'T DO THIS
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
const TVActorCard = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
name: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ name, onPress }, ref) => {
|
||||
return (
|
||||
<Pressable ref={ref} onPress={onPress}>
|
||||
<Text>{name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
// ✅ CORRECT - destination does NOT have hasTVPreferredFocus
|
||||
<TVFocusGuideView destinations={[firstCardRef]} />
|
||||
<ScrollView horizontal>
|
||||
{items.map((item, index) => (
|
||||
<Card
|
||||
ref={index === 0 ? setFirstCardRef : undefined}
|
||||
// No hasTVPreferredFocus - the focus guide handles directing focus here
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
```
|
||||
|
||||
### Step 2: Track Refs with State
|
||||
### Complete Bidirectional Example
|
||||
|
||||
```typescript
|
||||
const MyScreen: React.FC = () => {
|
||||
// Track the first actor card (for downward navigation)
|
||||
const [firstActorRef, setFirstActorRef] = useState<View | null>(null);
|
||||
// Track refs for focus navigation
|
||||
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
|
||||
|
||||
// Track the last option button (for upward navigation)
|
||||
const [lastButtonRef, setLastButtonRef] = useState<View | null>(null);
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Action buttons section */}
|
||||
<View style={{ flexDirection: "row", gap: 16 }}>
|
||||
<TVButton
|
||||
ref={setPlayButtonRef}
|
||||
onPress={handlePlay}
|
||||
hasTVPreferredFocus // OK here - this is NOT a focus guide destination
|
||||
>
|
||||
Play
|
||||
</TVButton>
|
||||
</View>
|
||||
|
||||
// ...
|
||||
{/* Cast section */}
|
||||
<View>
|
||||
<Text>Cast</Text>
|
||||
|
||||
{/* BOTH focus guides stacked together, above the list */}
|
||||
{/* Downward: Play button → first cast card */}
|
||||
{firstCastCardRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[firstCastCardRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
{/* Upward: cast → Play button */}
|
||||
{playButtonRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[playButtonRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Use ScrollView, not FlatList, for reliable focus */}
|
||||
<ScrollView horizontal style={{ overflow: "visible" }}>
|
||||
{cast.map((person, index) => (
|
||||
<CastCard
|
||||
key={person.id}
|
||||
person={person}
|
||||
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
||||
// ⚠️ NO hasTVPreferredFocus here - causes flickering!
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Place Focus Guides
|
||||
### Key Rules for Bidirectional Navigation
|
||||
|
||||
```typescript
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Option buttons */}
|
||||
<View>
|
||||
<TVOptionButton label="Quality" onPress={...} />
|
||||
<TVOptionButton label="Audio" onPress={...} />
|
||||
<TVOptionButton
|
||||
ref={setLastButtonRef} // Last button gets the ref
|
||||
label="Subtitles"
|
||||
onPress={...}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Focus guide: options → cast (downward navigation) */}
|
||||
{firstActorRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[firstActorRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cast section */}
|
||||
<View>
|
||||
<Text>Cast</Text>
|
||||
|
||||
{/* Focus guide: cast → options (upward navigation) */}
|
||||
{lastButtonRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[lastButtonRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollView horizontal>
|
||||
{actors.map((actor, index) => (
|
||||
<TVActorCard
|
||||
key={actor.id}
|
||||
ref={index === 0 ? setFirstActorRef : undefined} // First card gets the ref
|
||||
name={actor.name}
|
||||
onPress={...}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
```
|
||||
|
||||
### Step 4: Handle Dynamic "Last" Element
|
||||
|
||||
When the last button varies based on conditions (e.g., subtitle button only shows if subtitles exist), compute which one is last:
|
||||
|
||||
```typescript
|
||||
// Determine which button is last
|
||||
const lastOptionButton = useMemo(() => {
|
||||
if (hasSubtitles) return "subtitle";
|
||||
if (hasAudio) return "audio";
|
||||
return "quality";
|
||||
}, [hasSubtitles, hasAudio]);
|
||||
|
||||
// Pass ref only to the last one
|
||||
<TVOptionButton
|
||||
ref={lastOptionButton === "quality" ? setLastButtonRef : undefined}
|
||||
label="Quality"
|
||||
onPress={...}
|
||||
/>
|
||||
<TVOptionButton
|
||||
ref={lastOptionButton === "audio" ? setLastButtonRef : undefined}
|
||||
label="Audio"
|
||||
onPress={...}
|
||||
/>
|
||||
<TVOptionButton
|
||||
ref={lastOptionButton === "subtitle" ? setLastButtonRef : undefined}
|
||||
label="Subtitles"
|
||||
onPress={...}
|
||||
/>
|
||||
```
|
||||
1. **Stack both focus guides together** - Place them adjacent to each other, above the destination list
|
||||
2. **Do NOT use `hasTVPreferredFocus` on focus guide destinations** - This causes focus flickering
|
||||
3. **Use ScrollView instead of FlatList** - More reliable focus behavior
|
||||
4. **Use `useState` for refs, not `useRef`** - Triggers re-renders when refs are set
|
||||
|
||||
## Focus Guide Placement
|
||||
|
||||
The focus guide should be placed **between** the source and destination sections:
|
||||
The focus guides should be placed **together** above the destination section:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Option Buttons │ ← Source (going down)
|
||||
│ [Quality] [Audio] │
|
||||
│ Action Buttons │ ← Source (going down)
|
||||
│ [Play] [Request] │ Has hasTVPreferredFocus ✓
|
||||
└─────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────┐
|
||||
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
|
||||
│ destinations=[actor1] │ Catches downward navigation
|
||||
└─────────────────────────┘
|
||||
┌─────────────────────────┐
|
||||
│ TVFocusGuideView │ ← Invisible guide (height: 1px)
|
||||
│ destinations=[lastBtn] │ Catches upward navigation
|
||||
│ TVFocusGuideView │ ← Downward guide
|
||||
│ destinations=[card1] │
|
||||
├─────────────────────────┤
|
||||
│ Actor Cards │ ← Destination (going down)
|
||||
│ [👤] [👤] [👤] [👤] │ Source (going up)
|
||||
│ TVFocusGuideView │ ← Upward guide
|
||||
│ destinations=[playBtn] │ (stacked together)
|
||||
└─────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────┐
|
||||
│ Cast Cards (ScrollView)│ ← First card is destination
|
||||
│ [👤] [👤] [👤] [👤] │ NO hasTVPreferredFocus ✗
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Pattern with refSetter
|
||||
|
||||
For components that need to be focus guide destinations, use a `refSetter` callback prop:
|
||||
|
||||
```typescript
|
||||
interface TVCastCardProps {
|
||||
person: { id: number; name: string };
|
||||
onPress: () => void;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
person,
|
||||
onPress,
|
||||
refSetter,
|
||||
}) => {
|
||||
return (
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={onPress}
|
||||
// No hasTVPreferredFocus when this is a focus guide destination
|
||||
>
|
||||
<Text>{person.name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage
|
||||
<TVCastCard
|
||||
person={person}
|
||||
onPress={handlePress}
|
||||
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
||||
/>
|
||||
```
|
||||
|
||||
## Tips and Gotchas
|
||||
@@ -232,13 +281,25 @@ The focus guide should be placed **between** the source and destination sections
|
||||
</TVFocusGuideView>
|
||||
```
|
||||
|
||||
5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child:
|
||||
5. **Auto focus**: Use `autoFocus` to automatically focus the first focusable child when entering a region:
|
||||
```typescript
|
||||
<TVFocusGuideView autoFocus>
|
||||
{/* First focusable child will receive focus */}
|
||||
</TVFocusGuideView>
|
||||
```
|
||||
|
||||
**Warning**: Don't use `autoFocus` on a wrapper when you also have bidirectional focus guides - it can interfere with upward navigation.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Result | Fix |
|
||||
|---------|--------|-----|
|
||||
| Using `nextFocusUp`/`nextFocusDown` props | Doesn't work on tvOS | Use `TVFocusGuideView` |
|
||||
| Using FlatList for horizontal lists | Focus navigation unreliable | Use ScrollView |
|
||||
| `hasTVPreferredFocus` on focus guide destination | Focus flickering loop | Remove `hasTVPreferredFocus` from destination |
|
||||
| Focus guides placed separately | Focus flickering | Stack both guides together |
|
||||
| Using `useRef` for focus guide refs | Focus guide doesn't update | Use `useState` |
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See `components/ItemContent.tv.tsx` for a complete implementation of bidirectional focus navigation between playback options and the cast list.
|
||||
See `components/jellyseerr/tv/TVJellyseerrPage.tsx` for a complete implementation of bidirectional focus navigation between action buttons and a cast list.
|
||||
|
||||
Reference in New Issue
Block a user