From 65c4b7833d869a7405a06381ddb63ab9e835d347 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 26 Feb 2026 15:00:12 +0000 Subject: [PATCH] Blur effect on home screen (iOS 26) (#9935) --- src/Navigation.tsx | 25 +++++++++++++++++--- src/components/hooks/useHeaderOffset.ts | 11 +++++---- src/lib/hooks/useMinimalShellTransform.ts | 7 +++++- src/view/com/home/HomeHeaderLayoutMobile.tsx | 7 ++++-- src/view/com/util/MainScrollProvider.tsx | 9 +++++-- src/view/screens/Home.tsx | 4 ++-- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index feb9066062..4d05b95dc0 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -138,7 +138,7 @@ import { } from '#/components/dialogs/EmailDialog' import {useAnalytics} from '#/analytics' import {setNavigationMetadata} from '#/analytics/metadata' -import {IS_NATIVE, IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' @@ -685,10 +685,29 @@ function screenOptions(t: Theme) { function HomeTabNavigator() { const t = useTheme() + const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS + ? ({ + headerShown: true, + headerTransparent: true, + headerTitle: '', + scrollEdgeEffects: { + top: 'soft', + }, + } as const) + : {} + return ( - HomeScreen} /> - HomeScreen} /> + HomeScreen} + options={BLURRED_SCROLL_EDGE_EFFECT} + /> + HomeScreen} + options={BLURRED_SCROLL_EDGE_EFFECT} + /> {commonScreens(HomeTab as typeof Flat)} ) diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts index 2d18fb99bd..c965f522f1 100644 --- a/src/components/hooks/useHeaderOffset.ts +++ b/src/components/hooks/useHeaderOffset.ts @@ -1,14 +1,17 @@ import {useWindowDimensions} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useBreakpoints} from '#/alf' +import {IS_LIQUID_GLASS} from '#/env' export function useHeaderOffset() { - const {isDesktop, isTablet} = useWebMediaQueries() + const {gtMobile} = useBreakpoints() const {fontScale} = useWindowDimensions() - if (isDesktop || isTablet) { + const insets = useSafeAreaInsets() + if (gtMobile) { return 0 } - const navBarHeight = 52 + const navBarHeight = 52 + (IS_LIQUID_GLASS ? insets.top : 0) const tabBarPad = 10 + 10 + 3 // padding + border const normalLineHeight = 20 // matches tab bar const tabBarText = normalLineHeight * fontScale diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts index 6f16fa0f95..b240429779 100644 --- a/src/lib/hooks/useMinimalShellTransform.ts +++ b/src/lib/hooks/useMinimalShellTransform.ts @@ -1,13 +1,18 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useShellLayout} from '#/state/shell/shell-layout' +import {IS_LIQUID_GLASS} from '#/env' // Keep these separated so that we only pay for useAnimatedStyle that gets used. export function useMinimalShellHeaderTransform() { const {headerMode} = useMinimalShellMode() const {headerHeight} = useShellLayout() + const {top: topInset} = useSafeAreaInsets() + + const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 const headerTransform = useAnimatedStyle(() => { const headerModeValue = headerMode.get() @@ -19,7 +24,7 @@ export function useMinimalShellHeaderTransform() { translateY: interpolate( headerModeValue, [0, 1], - [0, -headerHeight.get()], + [0, headerPinnedHeight - headerHeight.get()], ), }, ], diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index 2f3a9b73c9..3fcbc3c93c 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -1,6 +1,6 @@ -import {type JSX} from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -17,16 +17,18 @@ import {ButtonIcon} from '#/components/Button' import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' +import {IS_LIQUID_GLASS} from '#/env' export function HomeHeaderLayoutMobile({ children, }: { children: React.ReactNode - tabBarAnchor: JSX.Element | null | undefined + tabBarAnchor: React.ReactElement | null | undefined }) { const t = useTheme() const {_} = useLingui() const {headerHeight} = useShellLayout() + const insets = useSafeAreaInsets() const headerMinimalShellTransform = useMinimalShellHeaderTransform() const {hasSession} = useSession() const playHaptic = useHaptics() @@ -42,6 +44,7 @@ export function HomeHeaderLayoutMobile({ left: 0, right: 0, }, + IS_LIQUID_GLASS && {paddingTop: insets.top}, headerMinimalShellTransform, ]} onLayout={e => { diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 56ada276e0..917dd999b0 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -6,18 +6,21 @@ import { useSharedValue, withSpring, } from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' -import {IS_NATIVE, IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' const WEB_HIDE_SHELL_THRESHOLD = 200 export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() + const {top: topInset} = useSafeAreaInsets() + const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 const startDragOffset = useSharedValue(null) const startMode = useSharedValue(null) const didJustRestoreScroll = useSharedValue(false) @@ -126,9 +129,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. const dy = offsetY - startDragOffsetValue + const hideDistance = headerHeight.get() - headerPinnedHeight const dProgress = interpolate( dy, - [-headerHeight.get(), headerHeight.get()], + [-hideDistance, hideDistance], [-1, 1], ) const newValue = clamp(startModeValue + dProgress, 0, 1) @@ -156,6 +160,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { }, [ headerHeight, + headerPinnedHeight, headerMode, setMode, startDragOffset, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4ee022167f..592cf23c07 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -36,7 +36,7 @@ import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import * as Layout from '#/components/Layout' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_WEB} from '#/env' import {useDemoMode} from '#/storage/hooks/demo-mode' type Props = NativeStackScreenProps @@ -79,7 +79,7 @@ export function HomeScreen(props: Props) { if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { return ( - +