fix(tv): add opening animations to bottom sheet option selectors

This commit is contained in:
Fredrik Burmester
2026-01-16 21:03:06 +01:00
parent e1e91ea1a6
commit 407ea69425
2 changed files with 172 additions and 97 deletions

View File

@@ -314,11 +314,39 @@ const TVOptionSelector = <T,>({
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
// Animation values
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]);
// Animate in when visible
useEffect(() => {
if (visible) {
// Reset values and animate in
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]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
@@ -341,7 +369,7 @@ const TVOptionSelector = <T,>({
if (!visible) return null;
return (
<View
<Animated.View
style={{
position: "absolute",
top: 0,
@@ -351,76 +379,79 @@ const TVOptionSelector = <T,>({
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
opacity: overlayOpacity,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
<Animated.View style={{ transform: [{ translateY: sheetTranslateY }] }}>
<BlurView
intensity={80}
tint='dark'
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
{/* Title */}
<Text
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{title}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</View>
{title}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};

View File

@@ -105,11 +105,39 @@ const TVOptionSelector = <T,>({
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
// Animation values
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
const sheetTranslateY = useRef(new RNAnimated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
// Animate in when visible
useEffect(() => {
if (visible) {
// Reset values and animate in
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
RNAnimated.parallel([
RNAnimated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}),
RNAnimated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: RNEasing.out(RNEasing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
@@ -132,44 +160,57 @@ const TVOptionSelector = <T,>({
if (!visible) return null;
return (
<View style={selectorStyles.overlay}>
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={selectorStyles.content}
<RNAnimated.View
style={[selectorStyles.overlay, { opacity: overlayOpacity }]}
>
<RNAnimated.View
style={[
selectorStyles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView
intensity={80}
tint='dark'
style={selectorStyles.blurContainer}
>
<Text style={selectorStyles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</View>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={selectorStyles.content}
>
<Text style={selectorStyles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={selectorStyles.scrollView}
contentContainerStyle={selectorStyles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</RNAnimated.View>
</RNAnimated.View>
);
};
@@ -543,6 +584,9 @@ const selectorStyles = StyleSheet.create({
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
// Container for the sheet to enable slide animation
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,