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 [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null); 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 initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected); const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0; return idx >= 0 ? idx : 0;
}, [options]); }, [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 // Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -341,7 +369,7 @@ const TVOptionSelector = <T,>({
if (!visible) return null; if (!visible) return null;
return ( return (
<View <Animated.View
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@@ -351,76 +379,79 @@ const TVOptionSelector = <T,>({
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end", justifyContent: "flex-end",
zIndex: 1000, zIndex: 1000,
opacity: overlayOpacity,
}} }}
> >
<BlurView <Animated.View style={{ transform: [{ translateY: sheetTranslateY }] }}>
intensity={80} <BlurView
tint='dark' intensity={80}
style={{ tint='dark'
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{ style={{
paddingTop: 24, borderTopLeftRadius: 24,
paddingBottom: 50, borderTopRightRadius: 24,
overflow: "visible", overflow: "hidden",
}} }}
> >
{/* Title */} <TVFocusGuideView
<Text autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{ style={{
fontSize: 18, paddingTop: 24,
fontWeight: "500", paddingBottom: 50,
color: "rgba(255,255,255,0.6)", overflow: "visible",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}} }}
> >
{title} {/* Title */}
</Text> <Text
style={{
{/* Horizontal options */} fontSize: 18,
{isReady && ( fontWeight: "500",
<ScrollView color: "rgba(255,255,255,0.6)",
horizontal marginBottom: 16,
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48, paddingHorizontal: 48,
paddingVertical: 10, textTransform: "uppercase",
gap: 12, letterSpacing: 1,
}} }}
> >
{options.map((option, index) => ( {title}
<TVOptionCard </Text>
key={index}
ref={ {/* Horizontal options */}
index === initialSelectedIndex ? firstCardRef : undefined {isReady && (
} <ScrollView
label={option.label} horizontal
selected={option.selected} showsHorizontalScrollIndicator={false}
hasTVPreferredFocus={index === initialSelectedIndex} style={{ overflow: "visible" }}
onPress={() => { contentContainerStyle={{
onSelect(option.value); paddingHorizontal: 48,
onClose(); paddingVertical: 10,
}} gap: 12,
/> }}
))} >
</ScrollView> {options.map((option, index) => (
)} <TVOptionCard
</TVFocusGuideView> key={index}
</BlurView> ref={
</View> 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 [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null); 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 initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected); const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0; return idx >= 0 ? idx : 0;
}, [options]); }, [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 // Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -132,44 +160,57 @@ const TVOptionSelector = <T,>({
if (!visible) return null; if (!visible) return null;
return ( return (
<View style={selectorStyles.overlay}> <RNAnimated.View
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}> style={[selectorStyles.overlay, { opacity: overlayOpacity }]}
<TVFocusGuideView >
autoFocus <RNAnimated.View
trapFocusUp style={[
trapFocusDown selectorStyles.sheetContainer,
trapFocusLeft { transform: [{ translateY: sheetTranslateY }] },
trapFocusRight ]}
style={selectorStyles.content} >
<BlurView
intensity={80}
tint='dark'
style={selectorStyles.blurContainer}
> >
<Text style={selectorStyles.title}>{title}</Text> <TVFocusGuideView
{isReady && ( autoFocus
<ScrollView trapFocusUp
horizontal trapFocusDown
showsHorizontalScrollIndicator={false} trapFocusLeft
style={selectorStyles.scrollView} trapFocusRight
contentContainerStyle={selectorStyles.scrollContent} style={selectorStyles.content}
> >
{options.map((option, index) => ( <Text style={selectorStyles.title}>{title}</Text>
<TVOptionCard {isReady && (
key={index} <ScrollView
ref={ horizontal
index === initialSelectedIndex ? firstCardRef : undefined showsHorizontalScrollIndicator={false}
} style={selectorStyles.scrollView}
label={option.label} contentContainerStyle={selectorStyles.scrollContent}
selected={option.selected} >
hasTVPreferredFocus={index === initialSelectedIndex} {options.map((option, index) => (
onPress={() => { <TVOptionCard
onSelect(option.value); key={index}
onClose(); ref={
}} index === initialSelectedIndex ? firstCardRef : undefined
/> }
))} label={option.label}
</ScrollView> selected={option.selected}
)} hasTVPreferredFocus={index === initialSelectedIndex}
</TVFocusGuideView> onPress={() => {
</BlurView> onSelect(option.value);
</View> onClose();
}}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</RNAnimated.View>
</RNAnimated.View>
); );
}; };
@@ -543,6 +584,9 @@ const selectorStyles = StyleSheet.create({
justifyContent: "flex-end", justifyContent: "flex-end",
zIndex: 1000, zIndex: 1000,
}, },
sheetContainer: {
// Container for the sheet to enable slide animation
},
blurContainer: { blurContainer: {
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,