Header blurred banner on overscroll (take 2) (#5474)
* 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 <dan.abramov@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user