experimental pager sticky header

This commit is contained in:
Samuel Newman
2024-10-02 14:19:44 +03:00
parent a1f4470098
commit ae7ceffff6
7 changed files with 198 additions and 28 deletions
@@ -19,7 +19,7 @@ export function GrowableAvatar({
}) {
const pagerContext = usePagerHeaderContext()
// pagerContext should only be present on iOS, but better safe than sorry
// iOS only
if (!pagerContext || !isIOS) {
return <View style={style}>{children}</View>
}
+31 -8
View File
@@ -4,41 +4,55 @@ import {ActivityIndicator} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
LayoutAnimationConfig,
runOnJS,
SharedValue,
useAnimatedProps,
useAnimatedReaction,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {useIsFetching} from '@tanstack/react-query'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs'
import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed'
import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens'
import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists'
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
import {atoms as a} from '#/alf'
import {NavHeader} from './NavHeader'
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
export function GrowableBanner({
backButton,
profile,
moderation,
children,
}: {
backButton?: React.ReactNode
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision
children: React.ReactNode
}) {
const pagerContext = usePagerHeaderContext()
// pagerContext should only be present on iOS, but better safe than sorry
if (!pagerContext || !isIOS) {
return (
<View style={[a.w_full, a.h_full]}>
{backButton}
{children}
</View>
<>
<LayoutAnimationConfig skipExiting skipEntering>
<NavHeader
profile={profile}
moderation={moderation}
backButton={backButton}
/>
</LayoutAnimationConfig>
<View style={[a.w_full, a.h_full]}>{children}</View>
</>
)
}
@@ -62,6 +76,7 @@ function GrowableBannerInner({
}) {
const isFetching = useIsProfileFetching()
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
const {top: topInset} = useSafeAreaInsets()
const animatedStyle = useAnimatedStyle(() => ({
transform: [
@@ -103,7 +118,7 @@ function GrowableBannerInner({
const animatedBackButtonStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
translateY: interpolate(scrollY.value, [-150, 0], [-150, 0], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
@@ -127,7 +142,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,
a.justify_center,
a.align_center,
{paddingTop: topInset},
]}>
<Animated.View style={[animatedSpinnerStyle]}>
<ActivityIndicator
key={animateSpinner ? 'spin' : 'stop'}
@@ -138,7 +160,8 @@ function GrowableBannerInner({
/>
</Animated.View>
</View>
<Animated.View style={[animatedBackButtonStyle]}>
<Animated.View
style={[animatedBackButtonStyle, a.absolute, {top: topInset}]}>
{backButton}
</Animated.View>
</>
+118
View File
@@ -0,0 +1,118 @@
import React, {useState} from 'react'
import {View} from 'react-native'
import Animated, {
runOnJS,
SlideInUp,
SlideOutUp,
useAnimatedReaction,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {StatusBar} from 'expo-status-bar'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles'
import {Shadow} from '#/state/cache/types'
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export const PROFILE_NAV_HEADER_HEIGHT = 50
interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision
backButton?: React.ReactNode
}
export function NavHeader({profile, moderation, backButton}: Props) {
const insets = useSafeAreaInsets()
const t = useTheme()
const {_} = useLingui()
return (
<View
style={[
{height: PROFILE_NAV_HEADER_HEIGHT + insets.top + a.border.borderWidth},
{paddingTop: insets.top},
a.w_full,
t.atoms.bg,
]}>
<View style={[a.flex_1, a.flex_row, a.gap_md, a.px_lg, a.align_center]}>
{backButton}
<UserAvatar
avatar={profile.avatar}
size={PROFILE_NAV_HEADER_HEIGHT - 12}
/>
<View style={[a.flex_1]}>
<Text style={[a.font_bold, a.text_lg, a.leading_tight]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text style={[a.text_sm, a.leading_tight]}>
{isInvalidHandle(profile.handle)
? _(msg`⚠Invalid Handle`)
: `@${profile.handle}`}
</Text>
</View>
</View>
</View>
)
}
export function NavHeaderScrollLinked({...props}: Props) {
const pagerContext = usePagerHeaderContext()
if (!pagerContext) {
throw new Error(
'NavHeaderScrollLinked must be used within a PagerHeaderProvider and cannot be used on web',
)
}
const {scrollY, headerHeight} = pagerContext
const [showHeader, setShowHeader] = useState(false)
const {top: topInset} = useSafeAreaInsets()
const navHeaderHeight = topInset + PROFILE_NAV_HEADER_HEIGHT
useAnimatedReaction(
() => scrollY.value > 60,
(value, prevValue) => {
if (value !== prevValue) {
runOnJS(setShowHeader)(value)
}
},
)
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateY: Math.min(scrollY.value, headerHeight - navHeaderHeight)},
],
}))
return (
<>
{showHeader ? (
<Animated.View
entering={SlideInUp.duration(200)}
exiting={SlideOutUp.duration(200)}
style={[
animatedStyle,
a.absolute,
{top: 0, left: 0, right: 0},
a.z_50,
]}>
<NavHeader {...props} />
</Animated.View>
) : (
<StatusBar style="light" />
)}
</>
)
}
+7 -4
View File
@@ -16,11 +16,12 @@ 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, native, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
import {NavHeaderScrollLinked} from './NavHeader'
interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
@@ -69,6 +70,8 @@ let ProfileHeaderShell = ({
pointerEvents={isIOS ? 'auto' : 'none'}
style={[a.relative, {height: 150}]}>
<GrowableBanner
profile={profile}
moderation={moderation}
backButton={
<>
{!isDesktop && !hideBackButton && (
@@ -105,9 +108,7 @@ let ProfileHeaderShell = ({
)}
</GrowableBanner>
</View>
{children}
{!isPlaceholderProfile && (
<View
style={[a.px_lg, a.py_xs]}
@@ -119,7 +120,6 @@ let ProfileHeaderShell = ({
)}
</View>
)}
<GrowableAvatar style={styles.aviPosition}>
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
@@ -143,6 +143,9 @@ let ProfileHeaderShell = ({
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>
{native(
<NavHeaderScrollLinked profile={profile} moderation={moderation} />,
)}
</View>
)
}
+22 -11
View File
@@ -1,40 +1,51 @@
import React, {useContext} from 'react'
import React, {useContext, useMemo} from 'react'
import {SharedValue} from 'react-native-reanimated'
import {isIOS} from '#/platform/detection'
import {isNative} from '#/platform/detection'
export const PagerHeaderContext =
React.createContext<SharedValue<number> | null>(null)
export const PagerHeaderContext = React.createContext<{
scrollY: SharedValue<number>
headerHeight: number
tabBarHeight: 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.
* overscroll on iOS, and handle the status bar. Not necessary to use this context provider on web.
*
* @platform ios
* @platform ios, android
*/
export function PagerHeaderProvider({
scrollY,
children,
headerHeight,
tabBarHeight,
}: {
scrollY: SharedValue<number>
headerHeight: number
tabBarHeight: number
children: React.ReactNode
}) {
const value = useMemo(
() => ({scrollY, headerHeight, tabBarHeight}),
[scrollY, headerHeight, tabBarHeight],
)
return (
<PagerHeaderContext.Provider value={scrollY}>
<PagerHeaderContext.Provider value={value}>
{children}
</PagerHeaderContext.Provider>
)
}
export function usePagerHeaderContext() {
const scrollY = useContext(PagerHeaderContext)
if (isIOS) {
if (!scrollY) {
const context = useContext(PagerHeaderContext)
if (isNative) {
if (!context) {
throw new Error(
'usePagerHeaderContext must be used within a HeaderProvider',
)
}
return {scrollY}
return context
} else {
return null
}
+13 -2
View File
@@ -43,6 +43,7 @@ export interface PagerWithHeaderProps {
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
headerMinHeight?: number
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
@@ -56,6 +57,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
onPageSelected,
onCurrentPageSelected,
allowHeaderOverScroll,
headerMinHeight,
}: PagerWithHeaderProps,
ref,
) {
@@ -83,7 +85,10 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider scrollY={scrollY}>
<PagerHeaderProvider
scrollY={scrollY}
headerHeight={headerOnlyHeight}
tabBarHeight={tabBarHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
@@ -97,6 +102,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
headerMinHeight={headerMinHeight}
/>
</PagerHeaderProvider>
)
@@ -113,6 +119,8 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
scrollY,
testID,
allowHeaderOverScroll,
headerMinHeight,
tabBarHeight,
],
)
@@ -231,6 +239,7 @@ let PagerTabBar = ({
onCurrentPageSelected,
onSelect,
allowHeaderOverScroll,
headerMinHeight = 0,
}: {
currentPage: number
headerOnlyHeight: number
@@ -244,9 +253,11 @@ let PagerTabBar = ({
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
allowHeaderOverScroll?: boolean
headerMinHeight?: number
}): React.ReactNode => {
const headerTransform = useAnimatedStyle(() => {
const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1
const translateY =
Math.min(scrollY.value, headerOnlyHeight - headerMinHeight) * -1
return {
transform: [
{
+6 -2
View File
@@ -1,5 +1,6 @@
import React, {useCallback, useMemo} from 'react'
import {StyleSheet} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
AppBskyActorDefs,
AppBskyGraphGetActorStarterPacks,
@@ -42,6 +43,7 @@ import {FAB} from '#/view/com/util/fab/FAB'
import {ListRef} from '#/view/com/util/List'
import {CenteredView} from '#/view/com/util/Views'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
import {PROFILE_NAV_HEADER_HEIGHT} from '#/screens/Profile/Header/NavHeader'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
import {web} from '#/alf'
@@ -58,7 +60,7 @@ interface SectionRef {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen(props: Props) {
return (
<Layout.Screen testID="profileScreen">
<Layout.Screen testID="profileScreen" disableTopPadding>
<ProfileScreenInner {...props} />
</Layout.Screen>
)
@@ -186,6 +188,7 @@ function ProfileScreenLoaded({
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const insets = useSafeAreaInsets()
const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null)
@@ -369,7 +372,8 @@ function ProfileScreenLoaded({
onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}
allowHeaderOverScroll>
allowHeaderOverScroll
headerMinHeight={insets.top + PROFILE_NAV_HEADER_HEIGHT}>
{showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLabelsSection