From f7a2368100d293c7ddc65bf27ade9fda66ecda95 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 25 Sep 2024 15:01:25 +0100 Subject: 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 --- src/screens/Profile/Header/GrowableBanner.tsx | 212 ++++++++++++++++++++++++++ src/screens/Profile/Header/Shell.tsx | 84 +++++----- src/state/queries/actor-starter-packs.ts | 4 +- src/state/queries/post-feed.ts | 20 +-- src/state/queries/profile-feedgens.ts | 2 +- src/state/queries/profile-lists.ts | 2 +- src/view/com/pager/PagerHeaderContext.tsx | 41 +++++ src/view/com/pager/PagerWithHeader.tsx | 31 ++-- src/view/com/util/UserBanner.tsx | 2 +- 9 files changed, 335 insertions(+), 63 deletions(-) create mode 100644 src/screens/Profile/Header/GrowableBanner.tsx create mode 100644 src/view/com/pager/PagerHeaderContext.tsx (limited to 'src') diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx new file mode 100644 index 000000000..bccc1e57e --- /dev/null +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -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 ( + + {backButton} + {children} + + ) + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableBannerInner({ + scrollY, + backButton, + children, +}: { + scrollY: SharedValue + 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 ( + <> + + {children} + + + + + + + + + {backButton} + + + ) +} + +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 +}) { + 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 +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 90c283090..d31912dda 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -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 @@ -63,20 +64,45 @@ let ProfileHeaderShell = ({ return ( - - {isPlaceholderProfile ? ( - - ) : ( - - )} + + + {!isDesktop && !hideBackButton && ( + + + + + + )} + + }> + {isPlaceholderProfile ? ( + + ) : ( + + )} + {children} @@ -93,19 +119,6 @@ let ProfileHeaderShell = ({ )} - {!isDesktop && !hideBackButton && ( - - - - - - )} [RQKEY_ROOT, did] export function useActorStarterPacksQuery({did}: {did?: string}) { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 7daf441ad..ae30ef0d6 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -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' @@ -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 || {}] } diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index b50a2a289..79d9735c9 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -8,7 +8,7 @@ const PAGE_SIZE = 50 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? -const RQKEY_ROOT = 'profile-feedgens' +export const RQKEY_ROOT = 'profile-feedgens' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileFeedgensQuery( diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 03c983ff8..5c9f9f0d6 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts' const PAGE_SIZE = 30 type RQPageParam = string | undefined -const RQKEY_ROOT = 'profile-lists' +export const RQKEY_ROOT = 'profile-lists' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx new file mode 100644 index 000000000..fd4cc7463 --- /dev/null +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -0,0 +1,41 @@ +import React, {useContext} from 'react' +import {SharedValue} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' + +export const PagerHeaderContext = + React.createContext | 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. + * + * @platform ios + */ +export function PagerHeaderProvider({ + scrollY, + children, +}: { + scrollY: SharedValue + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function usePagerHeaderContext() { + const scrollY = useContext(PagerHeaderContext) + if (isIOS) { + if (!scrollY) { + throw new Error( + 'usePagerHeaderContext must be used within a HeaderProvider', + ) + } + return {scrollY} + } else { + return null + } +} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 559bc70f1..528f7fdf2 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -22,6 +22,7 @@ import {ScrollProvider} from '#/lib/ScrollContext' import {isIOS} from '#/platform/detection' import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' import {ListMethods} from '../util/List' +import {PagerHeaderProvider} from './PagerHeaderContext' import {TabBar} from './TabBar' export interface PagerWithHeaderChildParams { @@ -82,20 +83,22 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + + + ) }, [ diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 13f4081fc..0e07a5745 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -202,7 +202,7 @@ const styles = StyleSheet.create({ }, bannerImage: { width: '100%', - height: 150, + height: '100%', }, defaultBanner: { backgroundColor: '#0070ff', -- cgit 1.4.1