[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
This commit is contained in:
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function StatusBarShadow() {
|
||||
return null
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user