From ffc63dc85fc191a51c3dc12c1afcd250f95036d5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 12 Dec 2024 17:46:19 +0000 Subject: [PATCH] [Layout] Bleed profile banner into safe area (#6967) * bleed profile banner into safe area (cherry picked from commit 50b3a4d0c6fd94b583ffe4efa65de35c81ae7f4e) * pointer events none when hidden (cherry picked from commit bae2c7b2dd6d7f858a98812196628308c0877755) * fix web (cherry picked from commit e3f9597170375f2903b6e567b963f008ec95aed1) * add status bar shadow * rm log * rm mini header * speed up animation * pass bool rather than int in light status bar --- src/App.native.tsx | 5 +- src/App.web.tsx | 5 +- src/components/Layout/Header/index.tsx | 3 +- src/screens/Profile/Header/GrowableBanner.tsx | 15 ++- src/screens/Profile/Header/Shell.tsx | 31 +++-- .../Profile/Header/StatusBarShadow.tsx | 56 +++++++++ .../Profile/Header/StatusBarShadow.web.tsx | 3 + src/screens/Profile/Header/index.tsx | 119 ++++++++++++++++-- src/state/shell/light-status-bar.tsx | 45 +++++++ src/view/com/pager/PagerHeaderContext.tsx | 30 +++-- src/view/com/pager/PagerWithHeader.tsx | 25 +++- src/view/com/pager/PagerWithHeader.web.tsx | 16 ++- src/view/screens/Profile.tsx | 10 +- src/view/shell/index.tsx | 6 +- 14 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 src/screens/Profile/Header/StatusBarShadow.tsx create mode 100644 src/screens/Profile/Header/StatusBarShadow.web.tsx create mode 100644 src/state/shell/light-status-bar.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index bc38eec799..c22a66e824 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -54,6 +54,7 @@ import { import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' +import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' @@ -209,7 +210,9 @@ function App() { - + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 808b0fc278..b7c5a56334 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -40,6 +40,7 @@ import { import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' +import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' @@ -181,7 +182,9 @@ function App() { - + + + diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index f05350dca9..16b484cea9 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -175,7 +175,8 @@ export function TitleText({ gtMobile && a.text_xl, style, ]} - numberOfLines={2}> + numberOfLines={2} + emoji> {children} ) diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx index 7f5a3cd6e4..3d2830439a 100644 --- a/src/screens/Profile/Header/GrowableBanner.tsx +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -10,6 +10,7 @@ import Animated, { useAnimatedReaction, useAnimatedStyle, } from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {BlurView} from 'expo-blur' import {useIsFetching} from '@tanstack/react-query' @@ -32,7 +33,7 @@ export function GrowableBanner({ }) { const pagerContext = usePagerHeaderContext() - // pagerContext should only be present on iOS, but better safe than sorry + // plain non-growable mode for Android/Web if (!pagerContext || !isIOS) { return ( @@ -60,6 +61,7 @@ function GrowableBannerInner({ backButton?: React.ReactNode children: React.ReactNode }) { + const {top: topInset} = useSafeAreaInsets() const isFetching = useIsProfileFetching() const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) @@ -104,7 +106,7 @@ function GrowableBannerInner({ const animatedBackButtonStyle = useAnimatedStyle(() => ({ transform: [ { - translateY: interpolate(scrollY.get(), [-150, 60], [-150, 60], { + translateY: interpolate(scrollY.get(), [-150, 10], [-150, 10], { extrapolateRight: Extrapolation.CLAMP, }), }, @@ -128,7 +130,14 @@ function GrowableBannerInner({ animatedProps={animatedBlurViewProps} /> - + @@ -43,7 +44,8 @@ let ProfileHeaderShell = ({ const {_} = useLingui() const {openLightbox} = useLightboxControls() const navigation = useNavigation() - const {isDesktop} = useWebMediaQueries() + const {top: topInset} = useSafeAreaInsets() + const aviRef = useHandleRef() const onPressBack = React.useCallback(() => { @@ -100,10 +102,11 @@ let ProfileHeaderShell = ({ + - {!isDesktop && !hideBackButton && ( + {!hideBackButton && ( - - + + )} @@ -186,7 +194,6 @@ export {ProfileHeaderShell} const styles = StyleSheet.create({ backBtnWrapper: { position: 'absolute', - top: 10, left: 10, width: 30, height: 30, diff --git a/src/screens/Profile/Header/StatusBarShadow.tsx b/src/screens/Profile/Header/StatusBarShadow.tsx new file mode 100644 index 0000000000..587b410510 --- /dev/null +++ b/src/screens/Profile/Header/StatusBarShadow.tsx @@ -0,0 +1,56 @@ +import Animated, {SharedValue, useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {LinearGradient} from 'expo-linear-gradient' + +import {isIOS} from '#/platform/detection' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {atoms as a} from '#/alf' + +const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient) + +export function StatusBarShadow() { + const {top: topInset} = useSafeAreaInsets() + const pagerContext = usePagerHeaderContext() + + if (isIOS && pagerContext) { + const {scrollY} = pagerContext + return + } + + return ( + + ) +} + +function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue}) { + const {top: topInset} = useSafeAreaInsets() + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: Math.min(0, scrollY.get()), + }, + ], + } + }) + + return ( + + ) +} diff --git a/src/screens/Profile/Header/StatusBarShadow.web.tsx b/src/screens/Profile/Header/StatusBarShadow.web.tsx new file mode 100644 index 0000000000..cd79871ea7 --- /dev/null +++ b/src/screens/Profile/Header/StatusBarShadow.web.tsx @@ -0,0 +1,3 @@ +export function StatusBarShadow() { + return null +} diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx index deb8063d99..7e4b9bb313 100644 --- a/src/screens/Profile/Header/index.tsx +++ b/src/screens/Profile/Header/index.tsx @@ -1,14 +1,25 @@ -import React, {memo} from 'react' -import {StyleSheet, View} from 'react-native' +import React, {memo, useState} from 'react' +import {LayoutChangeEvent, StyleSheet, View} from 'react-native' +import Animated, { + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import { AppBskyActorDefs, AppBskyLabelerDefs, ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' +import {useIsFocused} from '@react-navigation/native' +import {isNative} from '#/platform/detection' +import {useSetLightStatusBar} from '#/state/shell/light-status-bar' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -import {useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' import {ProfileHeaderStandard} from './ProfileHeaderStandard' @@ -43,20 +54,114 @@ interface Props { moderationOpts: ModerationOpts hideBackButton?: boolean isPlaceholderProfile?: boolean + setMinimumHeight: (height: number) => void } -let ProfileHeader = (props: Props): React.ReactNode => { +let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => { + let content if (props.profile.associated?.labeler) { if (!props.labeler) { - return + content = + } else { + content = } - return + } else { + content = } - return + + return ( + <> + {isNative && ( + setMinimumHeight(evt.nativeEvent.layout.height)} + profile={props.profile} + hideBackButton={props.hideBackButton} + /> + )} + {content} + + ) } ProfileHeader = memo(ProfileHeader) export {ProfileHeader} +const MinimalHeader = React.memo(function MinimalHeader({ + onLayout, +}: { + onLayout: (e: LayoutChangeEvent) => void + profile: AppBskyActorDefs.ProfileViewDetailed + hideBackButton?: boolean +}) { + const t = useTheme() + const insets = useSafeAreaInsets() + const ctx = usePagerHeaderContext() + const [visible, setVisible] = useState(false) + const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0) + const isScreenFocused = useIsFocused() + if (!ctx) throw new Error('MinimalHeader cannot be used on web') + const {scrollY, headerHeight} = ctx + + const animatedStyle = useAnimatedStyle(() => { + // if we don't yet have the min header height in JS, hide + if (!_WORKLET || minimalHeaderHeight === 0) { + return { + opacity: 0, + } + } + const pastThreshold = scrollY.get() > 100 + return { + opacity: pastThreshold + ? withTiming(1, {duration: 75}) + : withTiming(0, {duration: 75}), + transform: [ + { + translateY: Math.min( + scrollY.get(), + headerHeight - minimalHeaderHeight, + ), + }, + ], + } + }) + + useAnimatedReaction( + () => scrollY.get() > 100, + (value, prev) => { + if (prev !== value) { + runOnJS(setVisible)(value) + } + }, + ) + + useSetLightStatusBar(isScreenFocused && !visible) + + return ( + { + setMinimalHeaderHeight(evt.nativeEvent.layout.height) + onLayout(evt) + }} + style={[ + a.absolute, + a.z_50, + t.atoms.bg, + { + top: 0, + left: 0, + right: 0, + paddingTop: insets.top, + }, + animatedStyle, + ]} + /> + ) +}) +MinimalHeader.displayName = 'MinimalHeader' + const styles = StyleSheet.create({ avi: { position: 'absolute', diff --git a/src/state/shell/light-status-bar.tsx b/src/state/shell/light-status-bar.tsx new file mode 100644 index 0000000000..eb213adb93 --- /dev/null +++ b/src/state/shell/light-status-bar.tsx @@ -0,0 +1,45 @@ +import {createContext, useContext, useEffect, useState} from 'react' + +import {isWeb} from '#/platform/detection' +import {IS_DEV} from '#/env' + +const LightStatusBarRefCountContext = createContext(false) +const SetLightStatusBarRefCountContext = createContext +> | null>(null) + +export function useLightStatusBar() { + return useContext(LightStatusBarRefCountContext) +} + +export function useSetLightStatusBar(enabled: boolean) { + const setRefCount = useContext(SetLightStatusBarRefCountContext) + useEffect(() => { + // noop on web -sfn + if (isWeb) return + + if (!setRefCount) { + if (IS_DEV) + console.error( + 'useLightStatusBar was used without a SetLightStatusBarRefCountContext provider', + ) + return + } + if (enabled) { + setRefCount(prev => prev + 1) + return () => setRefCount(prev => prev - 1) + } + }, [enabled, setRefCount]) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [refCount, setRefCount] = useState(0) + + return ( + + 0}> + {children} + + + ) +} diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx index fd4cc74632..c979f7a6dd 100644 --- a/src/view/com/pager/PagerHeaderContext.tsx +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -1,40 +1,48 @@ import React, {useContext} from 'react' import {SharedValue} from 'react-native-reanimated' -import {isIOS} from '#/platform/detection' +import {isNative} from '#/platform/detection' -export const PagerHeaderContext = - React.createContext | null>(null) +export const PagerHeaderContext = React.createContext<{ + scrollY: SharedValue + headerHeight: number +} | null>(null) /** - * Passes the scrollY value to the pager header's banner, so it can grow on - * overscroll on iOS. Not necessary to use this context provider on other platforms. + * Passes information about the scroll position and header height down via + * context for the pager header to consume. * - * @platform ios + * @platform ios, android */ export function PagerHeaderProvider({ scrollY, + headerHeight, children, }: { scrollY: SharedValue + headerHeight: number children: React.ReactNode }) { + const value = React.useMemo( + () => ({scrollY, headerHeight}), + [scrollY, headerHeight], + ) return ( - + {children} ) } export function usePagerHeaderContext() { - const scrollY = useContext(PagerHeaderContext) - if (isIOS) { - if (!scrollY) { + const ctx = useContext(PagerHeaderContext) + if (isNative) { + if (!ctx) { throw new Error( 'usePagerHeaderContext must be used within a HeaderProvider', ) } - return {scrollY} + return ctx } else { return null } diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 6174459647..dcf141f84d 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -38,7 +38,11 @@ export interface PagerWithHeaderProps { | ((props: PagerWithHeaderChildParams) => JSX.Element) items: string[] isHeaderReady: boolean - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => JSX.Element initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void @@ -83,7 +87,9 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => JSX.Element onHeaderOnlyLayout: (height: number) => void onTabBarLayout: (e: LayoutChangeEvent) => void onCurrentPageSelected?: (index: number) => void @@ -246,8 +256,13 @@ let PagerTabBar = ({ dragProgress: SharedValue dragState: SharedValue<'idle' | 'dragging' | 'settling'> }): React.ReactNode => { + const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0) const headerTransform = useAnimatedStyle(() => { - const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 + const translateY = + Math.min( + scrollY.get(), + Math.max(headerOnlyHeight - minimumHeaderHeight, 0), + ) * -1 return { transform: [ { @@ -267,7 +282,7 @@ let PagerTabBar = ({ ref={headerRef} pointerEvents={isIOS ? 'auto' : 'box-none'} collapsable={false}> - {renderHeader?.()} + {renderHeader?.({setMinimumHeight: setMinimumHeaderHeight})} { // It wouldn't be enough to place `onLayout` on the parent node because // this would risk measuring before `isHeaderReady` has turned `true`. diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index 3335532b3d..98b32b3476 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -21,7 +21,11 @@ export interface PagerWithHeaderProps { | ((props: PagerWithHeaderChildParams) => JSX.Element) items: string[] isHeaderReady: boolean - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: () => void + }) => JSX.Element initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void @@ -115,7 +119,11 @@ let PagerTabBar = ({ currentPage: number items: string[] testID?: string - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: () => void + }) => JSX.Element isHeaderReady: boolean onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void @@ -123,7 +131,7 @@ let PagerTabBar = ({ }): React.ReactNode => { return ( <> - {renderHeader?.()} + {renderHeader?.({setMinimumHeight: noop})} {tabBarAnchor} (v: T | T[]): T[] { } return [v] } + +function noop() {} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 782e9b9c84..ebf1d955d8 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -43,6 +43,7 @@ import {ListRef} from '#/view/com/util/List' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {atoms as a} from '#/alf' import * as Layout from '#/components/Layout' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' @@ -56,7 +57,7 @@ interface SectionRef { type Props = NativeStackScreenProps export function ProfileScreen(props: Props) { return ( - + ) @@ -329,7 +330,11 @@ function ProfileScreenLoaded({ // rendering // = - const renderHeader = () => { + const renderHeader = ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => { return ( ) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 179e8858e0..a5e97610d0 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -18,6 +18,7 @@ import { useIsDrawerSwipeDisabled, useSetDrawerOpen, } from '#/state/shell' +import {useLightStatusBar} from '#/state/shell/light-status-bar' import {useCloseAnyActiveElement} from '#/state/util' import {Lightbox} from '#/view/com/lightbox/Lightbox' import {ModalsContainer} from '#/view/com/modals/Modal' @@ -154,6 +155,7 @@ function ShellInner() { export const Shell: React.FC = function ShellImpl() { const {fullyExpandedCount} = useDialogStateControlContext() + const lightStatusBar = useLightStatusBar() const t = useTheme() useIntentHandler() @@ -165,7 +167,9 @@ export const Shell: React.FC = function ShellImpl() { 0) + t.name !== 'light' || + (isIOS && fullyExpandedCount > 0) || + lightStatusBar ? 'light' : 'dark' }