Skip to content

Commit ffc63dc

Browse files
authored
[Layout] Bleed profile banner into safe area (bluesky-social#6967)
* bleed profile banner into safe area (cherry picked from commit 50b3a4d) * pointer events none when hidden (cherry picked from commit bae2c7b) * fix web (cherry picked from commit e3f9597) * add status bar shadow * rm log * rm mini header * speed up animation * pass bool rather than int in light status bar
1 parent 4b32b0a commit ffc63dc

File tree

14 files changed

+322
-47
lines changed

14 files changed

+322
-47
lines changed

src/App.native.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
import {readLastActiveAccount} from '#/state/session/util'
5555
import {Provider as ShellStateProvider} from '#/state/shell'
5656
import {Provider as ComposerProvider} from '#/state/shell/composer'
57+
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
5758
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
5859
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
5960
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -209,7 +210,9 @@ function App() {
209210
<SafeAreaProvider
210211
initialMetrics={initialWindowMetrics}>
211212
<IntentDialogProvider>
212-
<InnerApp />
213+
<LightStatusBarProvider>
214+
<InnerApp />
215+
</LightStatusBarProvider>
213216
</IntentDialogProvider>
214217
</SafeAreaProvider>
215218
</StarterPackProvider>

src/App.web.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
import {readLastActiveAccount} from '#/state/session/util'
4141
import {Provider as ShellStateProvider} from '#/state/shell'
4242
import {Provider as ComposerProvider} from '#/state/shell/composer'
43+
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
4344
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
4445
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
4546
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -181,7 +182,9 @@ function App() {
181182
<PortalProvider>
182183
<StarterPackProvider>
183184
<IntentDialogProvider>
184-
<InnerApp />
185+
<LightStatusBarProvider>
186+
<InnerApp />
187+
</LightStatusBarProvider>
185188
</IntentDialogProvider>
186189
</StarterPackProvider>
187190
</PortalProvider>

src/components/Layout/Header/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ export function TitleText({
175175
gtMobile && a.text_xl,
176176
style,
177177
]}
178-
numberOfLines={2}>
178+
numberOfLines={2}
179+
emoji>
179180
{children}
180181
</Text>
181182
)

src/screens/Profile/Header/GrowableBanner.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Animated, {
1010
useAnimatedReaction,
1111
useAnimatedStyle,
1212
} from 'react-native-reanimated'
13+
import {useSafeAreaInsets} from 'react-native-safe-area-context'
1314
import {BlurView} from 'expo-blur'
1415
import {useIsFetching} from '@tanstack/react-query'
1516

@@ -32,7 +33,7 @@ export function GrowableBanner({
3233
}) {
3334
const pagerContext = usePagerHeaderContext()
3435

35-
// pagerContext should only be present on iOS, but better safe than sorry
36+
// plain non-growable mode for Android/Web
3637
if (!pagerContext || !isIOS) {
3738
return (
3839
<View style={[a.w_full, a.h_full]}>
@@ -60,6 +61,7 @@ function GrowableBannerInner({
6061
backButton?: React.ReactNode
6162
children: React.ReactNode
6263
}) {
64+
const {top: topInset} = useSafeAreaInsets()
6365
const isFetching = useIsProfileFetching()
6466
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
6567

@@ -104,7 +106,7 @@ function GrowableBannerInner({
104106
const animatedBackButtonStyle = useAnimatedStyle(() => ({
105107
transform: [
106108
{
107-
translateY: interpolate(scrollY.get(), [-150, 60], [-150, 60], {
109+
translateY: interpolate(scrollY.get(), [-150, 10], [-150, 10], {
108110
extrapolateRight: Extrapolation.CLAMP,
109111
}),
110112
},
@@ -128,7 +130,14 @@ function GrowableBannerInner({
128130
animatedProps={animatedBlurViewProps}
129131
/>
130132
</Animated.View>
131-
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
133+
<View
134+
style={[
135+
a.absolute,
136+
a.inset_0,
137+
{top: topInset - (isIOS ? 15 : 0)},
138+
a.justify_center,
139+
a.align_center,
140+
]}>
132141
<Animated.View style={[animatedSpinnerStyle]}>
133142
<ActivityIndicator
134143
key={animateSpinner ? 'spin' : 'stop'}

src/screens/Profile/Header/Shell.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import React, {memo} from 'react'
22
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
33
import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
4+
import {useSafeAreaInsets} from 'react-native-safe-area-context'
45
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
5-
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
66
import {msg} from '@lingui/macro'
77
import {useLingui} from '@lingui/react'
88
import {useNavigation} from '@react-navigation/native'
99

1010
import {BACK_HITSLOP} from '#/lib/constants'
1111
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
12-
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
1312
import {NavigationProp} from '#/lib/routes/types'
1413
import {isIOS} from '#/platform/detection'
1514
import {Shadow} from '#/state/cache/types'
@@ -18,11 +17,13 @@ import {useSession} from '#/state/session'
1817
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
1918
import {UserAvatar} from '#/view/com/util/UserAvatar'
2019
import {UserBanner} from '#/view/com/util/UserBanner'
21-
import {atoms as a, useTheme} from '#/alf'
20+
import {atoms as a, platform, useTheme} from '#/alf'
21+
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
2222
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
2323
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
2424
import {GrowableAvatar} from './GrowableAvatar'
2525
import {GrowableBanner} from './GrowableBanner'
26+
import {StatusBarShadow} from './StatusBarShadow'
2627

2728
interface Props {
2829
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
@@ -43,7 +44,8 @@ let ProfileHeaderShell = ({
4344
const {_} = useLingui()
4445
const {openLightbox} = useLightboxControls()
4546
const navigation = useNavigation<NavigationProp>()
46-
const {isDesktop} = useWebMediaQueries()
47+
const {top: topInset} = useSafeAreaInsets()
48+
4749
const aviRef = useHandleRef()
4850

4951
const onPressBack = React.useCallback(() => {
@@ -100,23 +102,29 @@ let ProfileHeaderShell = ({
100102
<View
101103
pointerEvents={isIOS ? 'auto' : 'box-none'}
102104
style={[a.relative, {height: 150}]}>
105+
<StatusBarShadow />
103106
<GrowableBanner
104107
backButton={
105108
<>
106-
{!isDesktop && !hideBackButton && (
109+
{!hideBackButton && (
107110
<TouchableWithoutFeedback
108111
testID="profileHeaderBackBtn"
109112
onPress={onPressBack}
110113
hitSlop={BACK_HITSLOP}
111114
accessibilityRole="button"
112115
accessibilityLabel={_(msg`Back`)}
113116
accessibilityHint="">
114-
<View style={styles.backBtnWrapper}>
115-
<FontAwesomeIcon
116-
size={18}
117-
icon="angle-left"
118-
color="white"
119-
/>
117+
<View
118+
style={[
119+
styles.backBtnWrapper,
120+
{
121+
top: platform({
122+
web: 10,
123+
default: topInset,
124+
}),
125+
},
126+
]}>
127+
<ArrowLeftIcon size="lg" fill="white" />
120128
</View>
121129
</TouchableWithoutFeedback>
122130
)}
@@ -186,7 +194,6 @@ export {ProfileHeaderShell}
186194
const styles = StyleSheet.create({
187195
backBtnWrapper: {
188196
position: 'absolute',
189-
top: 10,
190197
left: 10,
191198
width: 30,
192199
height: 30,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Animated, {SharedValue, useAnimatedStyle} from 'react-native-reanimated'
2+
import {useSafeAreaInsets} from 'react-native-safe-area-context'
3+
import {LinearGradient} from 'expo-linear-gradient'
4+
5+
import {isIOS} from '#/platform/detection'
6+
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
7+
import {atoms as a} from '#/alf'
8+
9+
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient)
10+
11+
export function StatusBarShadow() {
12+
const {top: topInset} = useSafeAreaInsets()
13+
const pagerContext = usePagerHeaderContext()
14+
15+
if (isIOS && pagerContext) {
16+
const {scrollY} = pagerContext
17+
return <StatusBarShadowInnner scrollY={scrollY} />
18+
}
19+
20+
return (
21+
<LinearGradient
22+
colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']}
23+
style={[
24+
a.absolute,
25+
a.z_10,
26+
{height: topInset, top: 0, left: 0, right: 0},
27+
]}
28+
/>
29+
)
30+
}
31+
32+
function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue<number>}) {
33+
const {top: topInset} = useSafeAreaInsets()
34+
35+
const animatedStyle = useAnimatedStyle(() => {
36+
return {
37+
transform: [
38+
{
39+
translateY: Math.min(0, scrollY.get()),
40+
},
41+
],
42+
}
43+
})
44+
45+
return (
46+
<AnimatedLinearGradient
47+
colors={['rgba(0,0,0,0.5)', 'rgba(0,0,0,0)']}
48+
style={[
49+
animatedStyle,
50+
a.absolute,
51+
a.z_10,
52+
{height: topInset, top: 0, left: 0, right: 0},
53+
]}
54+
/>
55+
)
56+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function StatusBarShadow() {
2+
return null
3+
}

src/screens/Profile/Header/index.tsx

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1-
import React, {memo} from 'react'
2-
import {StyleSheet, View} from 'react-native'
1+
import React, {memo, useState} from 'react'
2+
import {LayoutChangeEvent, StyleSheet, View} from 'react-native'
3+
import Animated, {
4+
runOnJS,
5+
useAnimatedReaction,
6+
useAnimatedStyle,
7+
withTiming,
8+
} from 'react-native-reanimated'
9+
import {useSafeAreaInsets} from 'react-native-safe-area-context'
310
import {
411
AppBskyActorDefs,
512
AppBskyLabelerDefs,
613
ModerationOpts,
714
RichText as RichTextAPI,
815
} from '@atproto/api'
16+
import {useIsFocused} from '@react-navigation/native'
917

18+
import {isNative} from '#/platform/detection'
19+
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
20+
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
1021
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
11-
import {useTheme} from '#/alf'
22+
import {atoms as a, useTheme} from '#/alf'
1223
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
1324
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
1425

@@ -43,20 +54,114 @@ interface Props {
4354
moderationOpts: ModerationOpts
4455
hideBackButton?: boolean
4556
isPlaceholderProfile?: boolean
57+
setMinimumHeight: (height: number) => void
4658
}
4759

48-
let ProfileHeader = (props: Props): React.ReactNode => {
60+
let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => {
61+
let content
4962
if (props.profile.associated?.labeler) {
5063
if (!props.labeler) {
51-
return <ProfileHeaderLoading />
64+
content = <ProfileHeaderLoading />
65+
} else {
66+
content = <ProfileHeaderLabeler {...props} labeler={props.labeler} />
5267
}
53-
return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
68+
} else {
69+
content = <ProfileHeaderStandard {...props} />
5470
}
55-
return <ProfileHeaderStandard {...props} />
71+
72+
return (
73+
<>
74+
{isNative && (
75+
<MinimalHeader
76+
onLayout={evt => setMinimumHeight(evt.nativeEvent.layout.height)}
77+
profile={props.profile}
78+
hideBackButton={props.hideBackButton}
79+
/>
80+
)}
81+
{content}
82+
</>
83+
)
5684
}
5785
ProfileHeader = memo(ProfileHeader)
5886
export {ProfileHeader}
5987

88+
const MinimalHeader = React.memo(function MinimalHeader({
89+
onLayout,
90+
}: {
91+
onLayout: (e: LayoutChangeEvent) => void
92+
profile: AppBskyActorDefs.ProfileViewDetailed
93+
hideBackButton?: boolean
94+
}) {
95+
const t = useTheme()
96+
const insets = useSafeAreaInsets()
97+
const ctx = usePagerHeaderContext()
98+
const [visible, setVisible] = useState(false)
99+
const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0)
100+
const isScreenFocused = useIsFocused()
101+
if (!ctx) throw new Error('MinimalHeader cannot be used on web')
102+
const {scrollY, headerHeight} = ctx
103+
104+
const animatedStyle = useAnimatedStyle(() => {
105+
// if we don't yet have the min header height in JS, hide
106+
if (!_WORKLET || minimalHeaderHeight === 0) {
107+
return {
108+
opacity: 0,
109+
}
110+
}
111+
const pastThreshold = scrollY.get() > 100
112+
return {
113+
opacity: pastThreshold
114+
? withTiming(1, {duration: 75})
115+
: withTiming(0, {duration: 75}),
116+
transform: [
117+
{
118+
translateY: Math.min(
119+
scrollY.get(),
120+
headerHeight - minimalHeaderHeight,
121+
),
122+
},
123+
],
124+
}
125+
})
126+
127+
useAnimatedReaction(
128+
() => scrollY.get() > 100,
129+
(value, prev) => {
130+
if (prev !== value) {
131+
runOnJS(setVisible)(value)
132+
}
133+
},
134+
)
135+
136+
useSetLightStatusBar(isScreenFocused && !visible)
137+
138+
return (
139+
<Animated.View
140+
pointerEvents={visible ? 'auto' : 'none'}
141+
aria-hidden={!visible}
142+
accessibilityElementsHidden={!visible}
143+
importantForAccessibility={visible ? 'auto' : 'no-hide-descendants'}
144+
onLayout={evt => {
145+
setMinimalHeaderHeight(evt.nativeEvent.layout.height)
146+
onLayout(evt)
147+
}}
148+
style={[
149+
a.absolute,
150+
a.z_50,
151+
t.atoms.bg,
152+
{
153+
top: 0,
154+
left: 0,
155+
right: 0,
156+
paddingTop: insets.top,
157+
},
158+
animatedStyle,
159+
]}
160+
/>
161+
)
162+
})
163+
MinimalHeader.displayName = 'MinimalHeader'
164+
60165
const styles = StyleSheet.create({
61166
avi: {
62167
position: 'absolute',

0 commit comments

Comments
 (0)