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 +++++++++++++++++++------------ src/screens/Profile/Header/StatusBarShadow.tsx | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/screens/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 file(s) changed, 322 insertion(s)(+), 47 deletion(s)(-) diff --git a/src/App.native.tsx b/src/App.native.tsx --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -54,6 +54,7 @@ } from '#/state/session' 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 @@ - + + + diff --git a/src/App.web.tsx b/src/App.web.tsx --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -40,6 +40,7 @@ } from '#/state/session' 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 @@ - + + + diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -175,7 +175,8 @@ isIOS && align === 'platform' && a.text_center, 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 --- a/src/screens/Profile/Header/GrowableBanner.tsx +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -10,6 +10,7 @@ useAnimatedProps, 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 @@ children: React.ReactNode }) { 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 @@ scrollY: SharedValue backButton?: React.ReactNode children: React.ReactNode }) { + const {top: topInset} = useSafeAreaInsets() const isFetching = useIsProfileFetching() const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) @@ -104,7 +106,7 @@ 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 @@ tint="dark" animatedProps={animatedBlurViewProps} /> - + @@ -43,7 +44,8 @@ const {currentAccount} = useSession() 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 @@ + - {!isDesktop && !hideBackButton && ( + {!hideBackButton && ( - - + + )} @@ -186,7 +194,6 @@ 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 --- /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 --- /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 --- 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,19 +54,113 @@ descriptionRT: RichTextAPI | null 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: { diff --git a/src/state/shell/light-status-bar.tsx b/src/state/shell/light-status-bar.tsx new file mode 100644 --- /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 --- 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 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -38,7 +38,11 @@ | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] | ((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 @@ 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 @@ allowHeaderOverScroll?: boolean 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 @@ - {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 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -21,7 +21,11 @@ | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] | ((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 @@ }: { 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 @@ tabBarAnchor?: JSX.Element | null | undefined }): React.ReactNode => { return ( <> - {renderHeader?.()} + {renderHeader?.({setMinimumHeight: noop})} {tabBarAnchor} export function ProfileScreen(props: Props) { return ( - + ) @@ -329,7 +330,11 @@ // 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 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -18,6 +18,7 @@ useIsDrawerOpen, 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 @@ } export const Shell: React.FC = function ShellImpl() { const {fullyExpandedCount} = useDialogStateControlContext() + const lightStatusBar = useLightStatusBar() const t = useTheme() useIntentHandler() @@ -165,7 +167,9 @@ return ( 0) + t.name !== 'light' || + (isIOS && fullyExpandedCount > 0) || + lightStatusBar ? 'light' : 'dark' } -- tangled.sh