mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-09 23:48:41 +01:00
fix(tv): add opening animations to bottom sheet option selectors
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user