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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWQciPDiipBhqiW922Nivs
This commit is contained in:
Claude
2026-09-07 20:50:38 +00:00
parent 2dc7071e7d
commit 6671bd9f42
7 changed files with 38 additions and 51 deletions
+11
View File
@@ -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
+7 -10
View File
@@ -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<number>
@@ -52,15 +53,11 @@ export function useContributionRegistry(base?: SharedValue<number>) {
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)
}
}),
)
}
},
+5 -5
View File
@@ -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<ParamListBase>) {
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()
+4 -12
View File
@@ -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]),
)
+2 -7
View File
@@ -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])
}
+4 -7
View File
@@ -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],
)
+5 -10
View File
@@ -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(