From 6671bd9f42f8ee1d2404fb4bf81db71a2abcf08a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 20:50:38 +0000 Subject: [PATCH] Fix stuck bottom bar on Home and use the Reanimated 4 spring curve The header and bottom bar were being handed the same `withSpring()` animation object. Animation objects carry their own state, so the second shared value to receive it could stop partway, leaving the bar stuck half hidden after a slow scroll on Home. Each shared value now gets its own animation. Shell show/hide springs now share one config based on Reanimated 4's default critically damped curve, with the perceptual duration shortened from 550ms to 400ms. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JWQciPDiipBhqiW922Nivs --- src/lib/custom-animations/springs.ts | 11 +++++++++++ src/lib/hooks/useContributionRegistry.ts | 17 +++++++---------- src/lib/hooks/useScreenPresence/index.tsx | 10 +++++----- src/lib/hooks/useScreenPresence/index.web.tsx | 16 ++++------------ src/state/shell/minimal-mode.tsx | 9 ++------- src/view/com/util/MainScrollProvider.tsx | 11 ++++------- src/view/screens/Home.tsx | 15 +++++---------- 7 files changed, 38 insertions(+), 51 deletions(-) create mode 100644 src/lib/custom-animations/springs.ts diff --git a/src/lib/custom-animations/springs.ts b/src/lib/custom-animations/springs.ts new file mode 100644 index 0000000000..9818a6f347 --- /dev/null +++ b/src/lib/custom-animations/springs.ts @@ -0,0 +1,11 @@ +import {type WithSpringConfig} from 'react-native-reanimated' + +/** + * Spring used for showing and hiding shell UI (header, bottom bar, and things + * attached to them). This is Reanimated 4's default critically damped curve, + * whose perceptual duration is 550ms, sped up a little. + */ +export const SHELL_SPRING_CONFIG = { + duration: 400, + dampingRatio: 1, +} as const satisfies WithSpringConfig diff --git a/src/lib/hooks/useContributionRegistry.ts b/src/lib/hooks/useContributionRegistry.ts index a52cf746ae..ff48984f16 100644 --- a/src/lib/hooks/useContributionRegistry.ts +++ b/src/lib/hooks/useContributionRegistry.ts @@ -1,13 +1,14 @@ import {useCallback, useState} from 'react' import { clamp, - Reanimated3DefaultSpringConfig, type SharedValue, useDerivedValue, withSpring, } from 'react-native-reanimated' import {scheduleOnRN} from 'react-native-worklets' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' + type Contribution = { id: number value: SharedValue @@ -52,15 +53,11 @@ export function useContributionRegistry(base?: SharedValue) { setContributions(prev => [...prev, {id, value}]) return () => { value.set( - withSpring( - 0, - {...Reanimated3DefaultSpringConfig, overshootClamping: true}, - finished => { - if (finished) { - scheduleOnRN(remove, id) - } - }, - ), + withSpring(0, SHELL_SPRING_CONFIG, finished => { + if (finished) { + scheduleOnRN(remove, id) + } + }), ) } }, diff --git a/src/lib/hooks/useScreenPresence/index.tsx b/src/lib/hooks/useScreenPresence/index.tsx index fc4b125d6b..6ac1c1a361 100644 --- a/src/lib/hooks/useScreenPresence/index.tsx +++ b/src/lib/hooks/useScreenPresence/index.tsx @@ -1,6 +1,5 @@ import {useContext, useEffect} from 'react' import { - Reanimated3DefaultSpringConfig, type SharedValue, useDerivedValue, useSharedValue, @@ -16,6 +15,7 @@ import { type ParamListBase, } from '@react-navigation/native' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {ScreenPresenceContext} from './context' export {type ScreenPresence, useScreenPresence} from './context' @@ -117,10 +117,10 @@ function useAncestorsFocused(navigation: NavigationProp) { if (ancestors.length === 0) return const update = () => { focused.set( - withSpring(areAncestorsFocused(navigation) ? 1 : 0, { - ...Reanimated3DefaultSpringConfig, - overshootClamping: true, - }), + withSpring( + areAncestorsFocused(navigation) ? 1 : 0, + SHELL_SPRING_CONFIG, + ), ) } update() diff --git a/src/lib/hooks/useScreenPresence/index.web.tsx b/src/lib/hooks/useScreenPresence/index.web.tsx index eac97dc4a7..cd3ed9c5ca 100644 --- a/src/lib/hooks/useScreenPresence/index.web.tsx +++ b/src/lib/hooks/useScreenPresence/index.web.tsx @@ -1,11 +1,8 @@ import {useCallback} from 'react' -import { - Reanimated3DefaultSpringConfig, - useSharedValue, - withSpring, -} from 'react-native-reanimated' +import {useSharedValue, withSpring} from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {ScreenPresenceContext} from './context' export {type ScreenPresence, useScreenPresence} from './context' @@ -35,13 +32,8 @@ export function ScreenPresenceProvider({ useFocusEffect( useCallback(() => { - const spring = (to: number) => - withSpring(to, { - ...Reanimated3DefaultSpringConfig, - overshootClamping: true, - }) - presence.set(spring(1)) - return () => presence.set(spring(0)) + presence.set(withSpring(1, SHELL_SPRING_CONFIG)) + return () => presence.set(withSpring(0, SHELL_SPRING_CONFIG)) }, [presence]), ) diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx index 286c443228..e5e01d1afe 100644 --- a/src/state/shell/minimal-mode.tsx +++ b/src/state/shell/minimal-mode.tsx @@ -1,13 +1,13 @@ import {createContext, useContext, useEffect, useMemo} from 'react' import { type DerivedValue, - Reanimated3DefaultSpringConfig, type SharedValue, useAnimatedReaction, useSharedValue, withSpring, } from 'react-native-reanimated' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {useContributionRegistry} from '#/lib/hooks/useContributionRegistry' import {useScreenPresence} from '#/lib/hooks/useScreenPresence' @@ -84,12 +84,7 @@ export function useEnableMinimalShellMode({enabled} = {enabled: true}) { useEffect(() => { if (!enabled) return const unregister = register(contribution) - contribution.set( - withSpring(1, { - ...Reanimated3DefaultSpringConfig, - overshootClamping: true, - }), - ) + contribution.set(withSpring(1, SHELL_SPRING_CONFIG)) return unregister }, [enabled, register, contribution]) } diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index facc170c29..37e045b546 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -3,7 +3,6 @@ import {type NativeScrollEvent} from 'react-native' import { clamp, interpolate, - Reanimated3DefaultSpringConfig, type SharedValue, useAnimatedStyle, useSharedValue, @@ -12,6 +11,7 @@ import { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {EventEmitter} from 'eventemitter3' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {ScrollProvider} from '#/lib/ScrollContext' import {useMinimalShellScrollMode} from '#/state/shell/minimal-mode' import {useShellLayout} from '#/state/shell/shell-layout' @@ -106,12 +106,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const setMode = useCallback( (v: boolean) => { 'worklet' - const target = withSpring(v ? 1 : 0, { - ...Reanimated3DefaultSpringConfig, - overshootClamping: true, - }) - headerMode.set(target) - footerScrollMode.set(target) + // animation objects are stateful, so each shared value needs its own + headerMode.set(withSpring(v ? 1 : 0, SHELL_SPRING_CONFIG)) + footerScrollMode.set(withSpring(v ? 1 : 0, SHELL_SPRING_CONFIG)) }, [headerMode, footerScrollMode], ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cdf4b3d16d..f0f9125f50 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,9 +1,6 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react' import {ActivityIndicator, AppState, StyleSheet} from 'react-native' -import { - Reanimated3DefaultSpringConfig, - withSpring, -} from 'react-native-reanimated' +import {withSpring} from 'react-native-reanimated' import {useLingui} from '@lingui/react/macro' import {useFocusEffect} from '@react-navigation/native' @@ -12,6 +9,7 @@ import { PROD_DEFAULT_FEED, TIMELINE_SAVED_FEED, } from '#/lib/constants' +import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates' import {useSetTitle} from '#/lib/hooks/useSetTitle' @@ -160,12 +158,9 @@ function HomeScreenReady({ const footerScrollMode = useMinimalShellScrollMode() const showShell = useCallback(() => { 'worklet' - const shown = withSpring(0, { - ...Reanimated3DefaultSpringConfig, - overshootClamping: true, - }) - headerMode.set(shown) - footerScrollMode.set(shown) + // animation objects are stateful, so each shared value needs its own + headerMode.set(withSpring(0, SHELL_SPRING_CONFIG)) + footerScrollMode.set(withSpring(0, SHELL_SPRING_CONFIG)) }, [headerMode, footerScrollMode]) useFocusEffect(