diff options
Diffstat (limited to 'src/screens/Profile')
-rw-r--r-- | src/screens/Profile/Header/GrowableBanner.tsx | 15 | ||||
-rw-r--r-- | src/screens/Profile/Header/Shell.tsx | 31 | ||||
-rw-r--r-- | src/screens/Profile/Header/StatusBarShadow.tsx | 56 | ||||
-rw-r--r-- | src/screens/Profile/Header/StatusBarShadow.web.tsx | 3 | ||||
-rw-r--r-- | src/screens/Profile/Header/index.tsx | 119 |
5 files changed, 202 insertions, 22 deletions
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx index 7f5a3cd6e..3d2830439 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 ( <View style={[a.w_full, a.h_full]}> @@ -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} /> </Animated.View> - <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + <View + style={[ + a.absolute, + a.inset_0, + {top: topInset - (isIOS ? 15 : 0)}, + a.justify_center, + a.align_center, + ]}> <Animated.View style={[animatedSpinnerStyle]}> <ActivityIndicator key={animateSpinner ? 'spin' : 'stop'} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 573d38145..dedbfd201 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,15 +1,14 @@ import React, {memo} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {BACK_HITSLOP} from '#/lib/constants' import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {NavigationProp} from '#/lib/routes/types' import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' @@ -18,11 +17,13 @@ import {useSession} from '#/state/session' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserBanner} from '#/view/com/util/UserBanner' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, platform, useTheme} from '#/alf' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {GrowableAvatar} from './GrowableAvatar' import {GrowableBanner} from './GrowableBanner' +import {StatusBarShadow} from './StatusBarShadow' interface Props { profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> @@ -43,7 +44,8 @@ let ProfileHeaderShell = ({ const {_} = useLingui() const {openLightbox} = useLightboxControls() const navigation = useNavigation<NavigationProp>() - const {isDesktop} = useWebMediaQueries() + const {top: topInset} = useSafeAreaInsets() + const aviRef = useHandleRef() const onPressBack = React.useCallback(() => { @@ -100,10 +102,11 @@ let ProfileHeaderShell = ({ <View pointerEvents={isIOS ? 'auto' : 'box-none'} style={[a.relative, {height: 150}]}> + <StatusBarShadow /> <GrowableBanner backButton={ <> - {!isDesktop && !hideBackButton && ( + {!hideBackButton && ( <TouchableWithoutFeedback testID="profileHeaderBackBtn" onPress={onPressBack} @@ -111,12 +114,17 @@ let ProfileHeaderShell = ({ accessibilityRole="button" accessibilityLabel={_(msg`Back`)} accessibilityHint=""> - <View style={styles.backBtnWrapper}> - <FontAwesomeIcon - size={18} - icon="angle-left" - color="white" - /> + <View + style={[ + styles.backBtnWrapper, + { + top: platform({ + web: 10, + default: topInset, + }), + }, + ]}> + <ArrowLeftIcon size="lg" fill="white" /> </View> </TouchableWithoutFeedback> )} @@ -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 000000000..587b41051 --- /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 <StatusBarShadowInnner scrollY={scrollY} /> + } + + return ( + <LinearGradient + colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']} + style={[ + a.absolute, + a.z_10, + {height: topInset, top: 0, left: 0, right: 0}, + ]} + /> + ) +} + +function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue<number>}) { + const {top: topInset} = useSafeAreaInsets() + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: Math.min(0, scrollY.get()), + }, + ], + } + }) + + return ( + <AnimatedLinearGradient + colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']} + style={[ + animatedStyle, + a.absolute, + a.z_10, + {height: topInset, top: 0, left: 0, right: 0}, + ]} + /> + ) +} diff --git a/src/screens/Profile/Header/StatusBarShadow.web.tsx b/src/screens/Profile/Header/StatusBarShadow.web.tsx new file mode 100644 index 000000000..cd79871ea --- /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 deb8063d9..7e4b9bb31 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 <ProfileHeaderLoading /> + content = <ProfileHeaderLoading /> + } else { + content = <ProfileHeaderLabeler {...props} labeler={props.labeler} /> } - return <ProfileHeaderLabeler {...props} labeler={props.labeler} /> + } else { + content = <ProfileHeaderStandard {...props} /> } - return <ProfileHeaderStandard {...props} /> + + return ( + <> + {isNative && ( + <MinimalHeader + onLayout={evt => 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 ( + <Animated.View + pointerEvents={visible ? 'auto' : 'none'} + aria-hidden={!visible} + accessibilityElementsHidden={!visible} + importantForAccessibility={visible ? 'auto' : 'no-hide-descendants'} + onLayout={evt => { + 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', |