refactor(tv): extract shared components to reduce code duplication

This commit is contained in:
Fredrik Burmester
2026-01-18 14:45:18 +01:00
parent 60dd00ad7e
commit 5b7ded08cc
11 changed files with 804 additions and 1959 deletions

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Animated, Pressable, View, type ViewStyle } from "react-native";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVButtonProps {
onPress: () => void;
children: React.ReactNode;
variant?: "primary" | "secondary";
hasTVPreferredFocus?: boolean;
disabled?: boolean;
style?: ViewStyle;
scaleAmount?: number;
}
export const TVButton: React.FC<TVButtonProps> = ({
onPress,
children,
variant = "primary",
hasTVPreferredFocus = false,
disabled = false,
style,
scaleAmount = 1.05,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount });
const isPrimary = variant === "primary";
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
style,
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,60 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVCancelButtonProps {
onPress: () => void;
label?: string;
disabled?: boolean;
}
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
onPress,
label = "Cancel",
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
},
]}
>
<Ionicons
name='close'
size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,102 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionCardProps {
label: string;
sublabel?: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
width?: number;
height?: number;
}
export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
(
{
label,
sublabel,
selected,
hasTVPreferredFocus = false,
onPress,
width = 160,
height = 75,
},
ref,
) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
width,
height,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={{
fontSize: 12,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 2,
}}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,199 @@
import { BlurView } from "expo-blur";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton } from "./TVCancelButton";
import { TVOptionCard } from "./TVOptionCard";
export type TVOptionItem<T> = {
label: string;
sublabel?: string;
value: T;
selected: boolean;
};
export interface TVOptionSelectorProps<T> {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
cancelLabel?: string;
cardWidth?: number;
cardHeight?: number;
}
export const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
cancelLabel = "Cancel",
cardWidth = 160,
cardHeight = 75,
}: TVOptionSelectorProps<T>) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
width={cardWidth}
height={cardHeight}
/>
))}
</ScrollView>
)}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton onPress={onClose} label={cancelLabel} />
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTabButtonProps {
label: string;
active: boolean;
onSelect: () => void;
hasTVPreferredFocus?: boolean;
switchOnFocus?: boolean;
disabled?: boolean;
}
export const TVTabButton: React.FC<TVTabButtonProps> = ({
label,
active,
onSelect,
hasTVPreferredFocus = false,
switchOnFocus = false,
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
onFocus: switchOnFocus ? onSelect : undefined,
});
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "#fff"
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
borderBottomWidth: 2,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || active ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from "react";
import { Animated, Easing } from "react-native";
export interface UseTVFocusAnimationOptions {
scaleAmount?: number;
duration?: number;
onFocus?: () => void;
onBlur?: () => void;
}
export interface UseTVFocusAnimationReturn {
focused: boolean;
scale: Animated.Value;
handleFocus: () => void;
handleBlur: () => void;
animatedStyle: { transform: { scale: Animated.Value }[] };
}
export const useTVFocusAnimation = ({
scaleAmount = 1.05,
duration = 150,
onFocus,
onBlur,
}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = useCallback(
(value: number) => {
Animated.timing(scale, {
toValue: value,
duration,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
},
[scale, duration],
);
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(scaleAmount);
onFocus?.();
}, [animateTo, scaleAmount, onFocus]);
const handleBlur = useCallback(() => {
setFocused(false);
animateTo(1);
onBlur?.();
}, [animateTo, onBlur]);
const animatedStyle = { transform: [{ scale }] };
return {
focused,
scale,
handleFocus,
handleBlur,
animatedStyle,
};
};

17
components/tv/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export type {
UseTVFocusAnimationOptions,
UseTVFocusAnimationReturn,
} from "./hooks/useTVFocusAnimation";
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export type { TVButtonProps } from "./TVButton";
export { TVButton } from "./TVButton";
export type { TVCancelButtonProps } from "./TVCancelButton";
export { TVCancelButton } from "./TVCancelButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";