diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-09-25 15:01:25 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-25 15:01:25 +0100 |
commit | f7a2368100d293c7ddc65bf27ade9fda66ecda95 (patch) | |
tree | fa9c3699b61bced293d5e2eddf1d76d2530b9c4d | |
parent | bd393b1b387eeddff33a520f60f04387c9105379 (diff) | |
download | voidsky-f7a2368100d293c7ddc65bf27ade9fda66ecda95.tar.zst |
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>
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/screens/Profile/Header/GrowableBanner.tsx | 212 | ||||
-rw-r--r-- | src/screens/Profile/Header/Shell.tsx | 84 | ||||
-rw-r--r-- | src/state/queries/actor-starter-packs.ts | 4 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 20 | ||||
-rw-r--r-- | src/state/queries/profile-feedgens.ts | 2 | ||||
-rw-r--r-- | src/state/queries/profile-lists.ts | 2 | ||||
-rw-r--r-- | src/view/com/pager/PagerHeaderContext.tsx | 41 | ||||
-rw-r--r-- | src/view/com/pager/PagerWithHeader.tsx | 31 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 2 | ||||
-rw-r--r-- | yarn.lock | 5 |
11 files changed, 341 insertions, 63 deletions
diff --git a/package.json b/package.json index 4b3486545..5e4d896cc 100644 --- a/package.json +++ b/package.json @@ -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", 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 ( + <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 +} 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<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, diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 9de80b07d..487bcdfd9 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -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}) { 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<SharedValue<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. + * + * @platform ios + */ +export function PagerHeaderProvider({ + scrollY, + children, +}: { + scrollY: SharedValue<number> + children: React.ReactNode +}) { + return ( + <PagerHeaderContext.Provider value={scrollY}> + {children} + </PagerHeaderContext.Provider> + ) +} + +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<PagerRef, PagerWithHeaderProps>( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - <PagerTabBar - headerOnlyHeight={headerOnlyHeight} - items={items} - isHeaderReady={isHeaderReady} - renderHeader={renderHeader} - currentPage={currentPage} - onCurrentPageSelected={onCurrentPageSelected} - onTabBarLayout={onTabBarLayout} - onHeaderOnlyLayout={onHeaderOnlyLayout} - onSelect={props.onSelect} - scrollY={scrollY} - testID={testID} - allowHeaderOverScroll={allowHeaderOverScroll} - /> + <PagerHeaderProvider scrollY={scrollY}> + <PagerTabBar + headerOnlyHeight={headerOnlyHeight} + items={items} + isHeaderReady={isHeaderReady} + renderHeader={renderHeader} + currentPage={currentPage} + onCurrentPageSelected={onCurrentPageSelected} + onTabBarLayout={onTabBarLayout} + onHeaderOnlyLayout={onHeaderOnlyLayout} + onSelect={props.onSelect} + scrollY={scrollY} + testID={testID} + allowHeaderOverScroll={allowHeaderOverScroll} + /> + </PagerHeaderProvider> ) }, [ 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', diff --git a/yarn.lock b/yarn.lock index 17fe86237..db8a707a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12165,6 +12165,11 @@ expo-asset@~10.0.6: invariant "^2.2.4" md5-file "^3.2.3" +expo-blur@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958" + integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w== + expo-build-properties@^0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad" |