From 1eb6040ed910dd85600ff3c4baa09ce42095588c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 21:36:14 +0000 Subject: [PATCH] fix(mobile): sync profile status bar with scroll position Use the same scroll threshold as ProfileNavOverlay so system bar content switches to dark on light blur and back to light at the top. Co-authored-by: Dylan Jeffers --- .../profile-screen/ProfileNavOverlay.tsx | 10 +-- .../screens/profile-screen/ProfileScreen.tsx | 4 +- .../useProfileScrollStatusBar.ts | 88 +++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 packages/mobile/src/screens/profile-screen/useProfileScrollStatusBar.ts diff --git a/packages/mobile/src/screens/profile-screen/ProfileNavOverlay.tsx b/packages/mobile/src/screens/profile-screen/ProfileNavOverlay.tsx index e52694763f1..2cf11aacd5a 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileNavOverlay.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileNavOverlay.tsx @@ -36,7 +36,7 @@ export const PROFILE_NAV_CONTROLS_HEIGHT = 56 // Scroll distance (in px) over which the blur background fades in and the // button icons transition from white to the neutral theme color. -const FADE_DISTANCE = 60 +export const PROFILE_NAV_SCROLL_FADE_PX = 60 const useStyles = makeStyles(({ spacing }) => ({ root: { @@ -93,7 +93,7 @@ export const ProfileNavOverlay = () => { const blurBackgroundStyle = useAnimatedStyle(() => ({ opacity: scrollY - ? interpolate(scrollY.value, [0, FADE_DISTANCE], [0, 1], { + ? interpolate(scrollY.value, [0, PROFILE_NAV_SCROLL_FADE_PX], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) @@ -103,7 +103,7 @@ export const ProfileNavOverlay = () => { // White icons fade out as the user scrolls past the cover photo. const whiteIconsStyle = useAnimatedStyle(() => ({ opacity: scrollY - ? interpolate(scrollY.value, [0, FADE_DISTANCE], [1, 0], { + ? interpolate(scrollY.value, [0, PROFILE_NAV_SCROLL_FADE_PX], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) @@ -113,7 +113,7 @@ export const ProfileNavOverlay = () => { // Neutral icons fade in as the user scrolls past the cover photo. const neutralIconsStyle = useAnimatedStyle(() => ({ opacity: scrollY - ? interpolate(scrollY.value, [0, FADE_DISTANCE], [0, 1], { + ? interpolate(scrollY.value, [0, PROFILE_NAV_SCROLL_FADE_PX], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) @@ -124,7 +124,7 @@ export const ProfileNavOverlay = () => { // the cover photo has scrolled away. const titleStyle = useAnimatedStyle(() => ({ opacity: scrollY - ? interpolate(scrollY.value, [0, FADE_DISTANCE], [0, 1], { + ? interpolate(scrollY.value, [0, PROFILE_NAV_SCROLL_FADE_PX], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) diff --git a/packages/mobile/src/screens/profile-screen/ProfileScreen.tsx b/packages/mobile/src/screens/profile-screen/ProfileScreen.tsx index 15a6670e23c..794904fedca 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileScreen.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileScreen.tsx @@ -18,7 +18,6 @@ import { ScreenPrimaryContent } from 'app/components/core/Screen/ScreenPrimaryCo import { ScreenSecondaryContent } from 'app/components/core/Screen/ScreenSecondaryContent' import { OfflinePlaceholder } from 'app/components/offline-placeholder' import { useRoute } from 'app/hooks/useRoute' -import { useStatusBarStyle } from 'app/hooks/useStatusBarStyle' import { makeStyles } from 'app/styles' import { DeactivatedProfileTombstone } from './DeactivatedProfileTombstone' @@ -27,6 +26,7 @@ import { ProfileNavOverlay } from './ProfileNavOverlay' import { ProfileScreenSkeleton } from './ProfileScreenSkeleton' import { ProfileScrollContext } from './ProfileScrollContext' import { ProfileTabNavigator } from './ProfileTabs/ProfileTabNavigator' +import { useProfileScrollStatusBar } from './useProfileScrollStatusBar' import { useRefreshProfile } from './useRefreshProfile' const { setCurrentUser: setCurrentUserAction } = profilePageActions const { getIsReachable } = reachabilitySelectors @@ -96,7 +96,6 @@ export const ProfileScreen = () => { ) useFocusEffect(setCurrentUser) - useStatusBarStyle('light-content') const renderHeader = useCallback(() => , []) @@ -104,6 +103,7 @@ export const ProfileScreen = () => { // from inside the collapsible header via `ProfileScrollBridge` and read by // `ProfileNavOverlay` to animate its blur background and icon colors. const scrollY = useSharedValue(0) + useProfileScrollStatusBar(scrollY) return ( diff --git a/packages/mobile/src/screens/profile-screen/useProfileScrollStatusBar.ts b/packages/mobile/src/screens/profile-screen/useProfileScrollStatusBar.ts new file mode 100644 index 00000000000..15ae5988c63 --- /dev/null +++ b/packages/mobile/src/screens/profile-screen/useProfileScrollStatusBar.ts @@ -0,0 +1,88 @@ +import { useCallback, useRef } from 'react' + +import { useFocusEffect } from '@react-navigation/native' +import type { StatusBarProps } from 'react-native-bars' +import { NavigationBar, StatusBar as RNStatusBar } from 'react-native-bars' +import type { SharedValue } from 'react-native-reanimated' +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated' + +import { PROFILE_NAV_SCROLL_FADE_PX } from './ProfileNavOverlay' + +/** + * Status bar / nav bar style for profile: light icons over the cover at the top, + * default (dark) system bar content once the header has scrolled up — matching + * `ProfileNavOverlay` icon transition. + */ +export const useProfileScrollStatusBar = (scrollY: SharedValue) => { + const statusBarEntryRef = useRef(null) + const navigationBarEntryRef = useRef(null) + const lastBarStyleRef = useRef<'light-content' | 'dark-content' | null>(null) + + const applyBarStyle = useCallback( + (barStyle: 'light-content' | 'dark-content') => { + if (!statusBarEntryRef.current || !navigationBarEntryRef.current) { + return + } + if (lastBarStyleRef.current === barStyle) { + return + } + lastBarStyleRef.current = barStyle + statusBarEntryRef.current = RNStatusBar.replaceStackEntry( + statusBarEntryRef.current, + { barStyle } + ) + navigationBarEntryRef.current = NavigationBar.replaceStackEntry( + navigationBarEntryRef.current, + { barStyle } + ) + }, + [] + ) + + useFocusEffect( + useCallback(() => { + lastBarStyleRef.current = null + statusBarEntryRef.current = RNStatusBar.pushStackEntry({ + barStyle: 'light-content' + }) + navigationBarEntryRef.current = NavigationBar.pushStackEntry({ + barStyle: 'light-content' + }) + + // Align with restored scroll position (reaction only fires on change). + requestAnimationFrame(() => { + const barStyle = + scrollY.value < PROFILE_NAV_SCROLL_FADE_PX + ? 'light-content' + : 'dark-content' + applyBarStyle(barStyle) + }) + + return () => { + lastBarStyleRef.current = null + if (statusBarEntryRef.current) { + RNStatusBar.popStackEntry(statusBarEntryRef.current) + statusBarEntryRef.current = null + } + if (navigationBarEntryRef.current) { + NavigationBar.popStackEntry(navigationBarEntryRef.current) + navigationBarEntryRef.current = null + } + } + }, [applyBarStyle, scrollY]) + ) + + useAnimatedReaction( + () => scrollY.value, + (y, prev) => { + const atTop = y < PROFILE_NAV_SCROLL_FADE_PX + const barStyle = atTop ? 'light-content' : 'dark-content' + const prevY = prev ?? 0 + const prevAtTop = prevY < PROFILE_NAV_SCROLL_FADE_PX + if (atTop !== prevAtTop) { + runOnJS(applyBarStyle)(barStyle) + } + }, + [applyBarStyle] + ) +}