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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-30 20:31:13 +08:00
parent 86d75b9252
commit 9711a0bc20
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
1 changed files with 89 additions and 2 deletions

View File

@ -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 (
<NavigationBlurEffectHeader
title={viewTitle}
headerTitle={<AnimatedTitle title={viewTitle} />}
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<string | undefined>()
const [isAnimating, setIsAnimating] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | undefined>(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 (
<View className="relative">
{/* Current/New Title */}
<Animated.View style={newTitleStyle}>
<FakeNativeHeaderTitle>{displayedTitle}</FakeNativeHeaderTitle>
</Animated.View>
{/* Old Title (during transition) */}
{oldTitle && isAnimating && (
<Animated.View className="absolute inset-0" style={oldTitleStyle}>
<FakeNativeHeaderTitle>{oldTitle}</FakeNativeHeaderTitle>
</Animated.View>
)}
</View>
)
}