Skip to content

Commit

Permalink
Header blurred banner on overscroll (take 2) (#5474)
Browse files Browse the repository at this point in the history
* grow banner when overscrolling

* add blurview

* make backdrop blur as it scrolls

* add activity indicator

* use rotated spinner instead of arrow

* persist position of back button

* make back button prettier

* make blur less jarring

* Unify effects

* Tweak impl

* determine if should animate based on scroll amount

* sign comment

---------

Co-authored-by: Dan Abramov <[email protected]>
  • Loading branch information
mozzius and gaearon authored Sep 25, 2024
1 parent bd393b1 commit f7a2368
Show file tree
Hide file tree
Showing 11 changed files with 341 additions and 63 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"eventemitter3": "^5.0.1",
"expo": "^51.0.8",
"expo-application": "^5.9.1",
"expo-blur": "^13.0.2",
"expo-build-properties": "^0.12.1",
"expo-camera": "~15.0.9",
"expo-clipboard": "^6.0.3",
Expand Down
212 changes: 212 additions & 0 deletions src/screens/Profile/Header/GrowableBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React, {useEffect, useState} from 'react'
import {View} from 'react-native'
import {ActivityIndicator} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
runOnJS,
SharedValue,
useAnimatedProps,
useAnimatedReaction,
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {useIsFetching} from '@tanstack/react-query'

import {isIOS} from '#/platform/detection'
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'

const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)

export function GrowableBanner({
backButton,
children,
}: {
backButton?: React.ReactNode
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>
)
}

const {scrollY} = pagerContext

return (
<GrowableBannerInner scrollY={scrollY} backButton={backButton}>
{children}
</GrowableBannerInner>
)
}

function GrowableBannerInner({
scrollY,
backButton,
children,
}: {
scrollY: SharedValue<number>
backButton?: React.ReactNode
children: React.ReactNode
}) {
const isFetching = useIsProfileFetching()
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})

const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(scrollY.value, [-150, 0], [2, 1], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))

const animatedBlurViewProps = useAnimatedProps(() => {
return {
intensity: interpolate(
scrollY.value,
[-400, -100, -15],
[70, 60, 0],
Extrapolation.CLAMP,
),
}
})

const animatedSpinnerStyle = useAnimatedStyle(() => {
return {
display: scrollY.value < 0 ? 'flex' : 'none',
opacity: interpolate(
scrollY.value,
[-60, -15],
[1, 0],
Extrapolation.CLAMP,
),
transform: [
{translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])},
{rotate: '90deg'},
],
}
})

const animatedBackButtonStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))

return (
<>
<Animated.View
style={[
a.absolute,
{left: 0, right: 0, bottom: 0},
{height: 150},
{transformOrigin: 'bottom'},
animatedStyle,
]}>
{children}
<AnimatedBlurView
style={[a.absolute, a.inset_0]}
tint="dark"
animatedProps={animatedBlurViewProps}
/>
</Animated.View>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<Animated.View style={[animatedSpinnerStyle]}>
<ActivityIndicator
key={animateSpinner ? 'spin' : 'stop'}
size="large"
color="white"
animating={animateSpinner}
hidesWhenStopped={false}
/>
</Animated.View>
</View>
<Animated.View style={[animatedBackButtonStyle]}>
{backButton}
</Animated.View>
</>
)
}

function useIsProfileFetching() {
// are any of the profile-related queries fetching?
return [
useIsFetching({queryKey: [FEED_RQKEY_ROOT]}),
useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}),
useIsFetching({queryKey: [LIST_RQKEY_ROOT]}),
useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}),
].some(isFetching => isFetching)
}

function useShouldAnimateSpinner({
isFetching,
scrollY,
}: {
isFetching: boolean
scrollY: SharedValue<number>
}) {
const [isOverscrolled, setIsOverscrolled] = useState(false)
// HACK: it reports a scroll pos of 0 for a tick when fetching finishes
// so paper over that by keeping it true for a bit -sfn
const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10)

useAnimatedReaction(
() => scrollY.value < -5,
(value, prevValue) => {
if (value !== prevValue) {
runOnJS(setIsOverscrolled)(value)
}
},
[scrollY],
)

const [isAnimating, setIsAnimating] = useState(isFetching)

if (isFetching && !isAnimating) {
setIsAnimating(true)
}

if (!isFetching && isAnimating && !stickyIsOverscrolled) {
setIsAnimating(false)
}

return isAnimating
}

// stayed true for at least `delay` ms before returning to false
function useStickyToggle(value: boolean, delay: number) {
const [prevValue, setPrevValue] = useState(value)
const [isSticking, setIsSticking] = useState(false)

useEffect(() => {
if (isSticking) {
const timeout = setTimeout(() => setIsSticking(false), delay)
return () => clearTimeout(timeout)
}
}, [isSticking, delay])

if (value !== prevValue) {
setIsSticking(prevValue) // Going true -> false should stick.
setPrevValue(value)
return prevValue ? true : value
}

return isSticking ? true : value
}
84 changes: 50 additions & 34 deletions src/screens/Profile/Header/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'

import {BACK_HITSLOP} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {isIOS} from 'platform/detection'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserBanner} from 'view/com/util/UserBanner'
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 {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {GrowableBanner} from './GrowableBanner'

interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
Expand Down Expand Up @@ -63,20 +64,45 @@ let ProfileHeaderShell = ({

return (
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View pointerEvents={isIOS ? 'auto' : 'none'}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height={150}
style={{borderRadius: 0}}
/>
) : (
<UserBanner
type={profile.associated?.labeler ? 'labeler' : 'default'}
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
<View
pointerEvents={isIOS ? 'auto' : 'none'}
style={[a.relative, {height: 150}]}>
<GrowableBanner
backButton={
<>
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<FontAwesomeIcon
size={18}
icon="angle-left"
color="white"
/>
</View>
</TouchableWithoutFeedback>
)}
</>
}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height="100%"
style={{borderRadius: 0}}
/>
) : (
<UserBanner
type={profile.associated?.labeler ? 'labeler' : 'default'}
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
</GrowableBanner>
</View>

{children}
Expand All @@ -93,19 +119,6 @@ let ProfileHeaderShell = ({
</View>
)}

{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
Expand Down Expand Up @@ -144,6 +157,9 @@ const styles = StyleSheet.create({
borderRadius: 15,
// @ts-ignore web only
cursor: 'pointer',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
alignItems: 'center',
justifyContent: 'center',
},
backBtn: {
width: 30,
Expand Down
4 changes: 2 additions & 2 deletions src/state/queries/actor-starter-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'

import {useAgent} from 'state/session'
import {useAgent} from '#/state/session'

const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY = (did?: string) => [RQKEY_ROOT, did]

export function useActorStarterPacksQuery({did}: {did?: string}) {
Expand Down
20 changes: 10 additions & 10 deletions src/state/queries/post-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'

import {AuthorFeedAPI} from '#/lib/api/feed/author'
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {FollowingFeedAPI} from '#/lib/api/feed/following'
import {HomeFeedAPI} from '#/lib/api/feed/home'
import {LikesFeedAPI} from '#/lib/api/feed/likes'
import {ListFeedAPI} from '#/lib/api/feed/list'
import {MergeFeedAPI} from '#/lib/api/feed/merge'
import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
Expand Down Expand Up @@ -65,7 +65,7 @@ export interface FeedParams {

type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined

const RQKEY_ROOT = 'post-feed'
export const RQKEY_ROOT = 'post-feed'
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return [RQKEY_ROOT, feedDesc, params || {}]
}
Expand Down
Loading

0 comments on commit f7a2368

Please sign in to comment.