From 9711a0bc20e70339f7ca6d7b6307df50a27286e4 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 30 Jun 2025 20:31:13 +0800 Subject: [PATCH] feat(timeline): implement animated title transition in TimelineHeader - Added AnimatedTitle component to handle cross-fade animation for title changes in the TimelineHeader. - Utilized useSharedValue and useAnimatedStyle from react-native-reanimated for smooth opacity transitions. - Improved user experience by visually indicating title changes during navigation. Signed-off-by: Innei --- .../screen/TimelineSelectorProvider.tsx | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx index 56722ec6f..7dfbbc4e8 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx @@ -1,8 +1,17 @@ -import { useMemo } from "react" +import type { FC } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { View } from "react-native" +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated" +import { FakeNativeHeaderTitle } from "@/src/components/layouts/header/FakeNativeHeaderTitle" import { DefaultHeaderBackButton } from "@/src/components/layouts/header/NavigationHeader" import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView" +import { gentleSpringPreset } from "@/src/constants/spring" import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui" import { ActionGroup, @@ -27,7 +36,7 @@ export function TimelineHeader({ feedId }: { feedId?: string }) { return ( } isLoading={(isFeed || isTimeline) && isFetching} headerLeft={useMemo( () => @@ -52,3 +61,81 @@ export function TimelineHeader({ feedId }: { feedId?: string }) { /> ) } + +const AnimatedTitle: FC<{ + title: string | undefined +}> = ({ title }) => { + // Track titles and animation state + const [displayedTitle, setDisplayedTitle] = useState(title) + const [oldTitle, setOldTitle] = useState() + const [isAnimating, setIsAnimating] = useState(false) + const timeoutRef = useRef(undefined) + + // Animation values - only opacity for cross-fade effect + const newTitleOpacity = useSharedValue(1) + const oldTitleOpacity = useSharedValue(0) + + // Handle title changes with cross-fade animation + useEffect(() => { + if (title !== displayedTitle && title !== undefined && !isAnimating) { + setIsAnimating(true) + setOldTitle(displayedTitle) + + // Start cross-fade animation + oldTitleOpacity.value = 1 + newTitleOpacity.value = 0 + + // Fade out old title and fade in new title + oldTitleOpacity.value = withSpring(0, gentleSpringPreset) + + // Update displayed title and fade in + timeoutRef.current = setTimeout(() => { + setDisplayedTitle(title) + newTitleOpacity.value = withSpring(1, gentleSpringPreset, () => { + runOnJS(setIsAnimating)(false) + runOnJS(setOldTitle)(void 0) + }) + }, 150) + } + }, [title, displayedTitle, isAnimating, newTitleOpacity, oldTitleOpacity]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + // Initialize displayed title + useEffect(() => { + if (displayedTitle === undefined && title !== undefined) { + setDisplayedTitle(title) + } + }, [title, displayedTitle]) + + const newTitleStyle = useAnimatedStyle(() => ({ + opacity: newTitleOpacity.value, + })) + + const oldTitleStyle = useAnimatedStyle(() => ({ + opacity: oldTitleOpacity.value, + })) + + return ( + + {/* Current/New Title */} + + {displayedTitle} + + + {/* Old Title (during transition) */} + {oldTitle && isAnimating && ( + + {oldTitle} + + )} + + ) +}