my fork of the bluesky client

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>

authored by samuel.fm

Dan Abramov and committed by
GitHub
f7a23681 bd393b1b

+341 -63
+1
package.json
··· 120 120 "eventemitter3": "^5.0.1", 121 121 "expo": "^51.0.8", 122 122 "expo-application": "^5.9.1", 123 + "expo-blur": "^13.0.2", 123 124 "expo-build-properties": "^0.12.1", 124 125 "expo-camera": "~15.0.9", 125 126 "expo-clipboard": "^6.0.3",
+212
src/screens/Profile/Header/GrowableBanner.tsx
··· 1 + import React, {useEffect, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {ActivityIndicator} from 'react-native' 4 + import Animated, { 5 + Extrapolation, 6 + interpolate, 7 + runOnJS, 8 + SharedValue, 9 + useAnimatedProps, 10 + useAnimatedReaction, 11 + useAnimatedStyle, 12 + } from 'react-native-reanimated' 13 + import {BlurView} from 'expo-blur' 14 + import {useIsFetching} from '@tanstack/react-query' 15 + 16 + import {isIOS} from '#/platform/detection' 17 + import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' 18 + import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' 19 + import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' 20 + import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' 21 + import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' 22 + import {atoms as a} from '#/alf' 23 + 24 + const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) 25 + 26 + export function GrowableBanner({ 27 + backButton, 28 + children, 29 + }: { 30 + backButton?: React.ReactNode 31 + children: React.ReactNode 32 + }) { 33 + const pagerContext = usePagerHeaderContext() 34 + 35 + // pagerContext should only be present on iOS, but better safe than sorry 36 + if (!pagerContext || !isIOS) { 37 + return ( 38 + <View style={[a.w_full, a.h_full]}> 39 + {backButton} 40 + {children} 41 + </View> 42 + ) 43 + } 44 + 45 + const {scrollY} = pagerContext 46 + 47 + return ( 48 + <GrowableBannerInner scrollY={scrollY} backButton={backButton}> 49 + {children} 50 + </GrowableBannerInner> 51 + ) 52 + } 53 + 54 + function GrowableBannerInner({ 55 + scrollY, 56 + backButton, 57 + children, 58 + }: { 59 + scrollY: SharedValue<number> 60 + backButton?: React.ReactNode 61 + children: React.ReactNode 62 + }) { 63 + const isFetching = useIsProfileFetching() 64 + const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) 65 + 66 + const animatedStyle = useAnimatedStyle(() => ({ 67 + transform: [ 68 + { 69 + scale: interpolate(scrollY.value, [-150, 0], [2, 1], { 70 + extrapolateRight: Extrapolation.CLAMP, 71 + }), 72 + }, 73 + ], 74 + })) 75 + 76 + const animatedBlurViewProps = useAnimatedProps(() => { 77 + return { 78 + intensity: interpolate( 79 + scrollY.value, 80 + [-400, -100, -15], 81 + [70, 60, 0], 82 + Extrapolation.CLAMP, 83 + ), 84 + } 85 + }) 86 + 87 + const animatedSpinnerStyle = useAnimatedStyle(() => { 88 + return { 89 + display: scrollY.value < 0 ? 'flex' : 'none', 90 + opacity: interpolate( 91 + scrollY.value, 92 + [-60, -15], 93 + [1, 0], 94 + Extrapolation.CLAMP, 95 + ), 96 + transform: [ 97 + {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])}, 98 + {rotate: '90deg'}, 99 + ], 100 + } 101 + }) 102 + 103 + const animatedBackButtonStyle = useAnimatedStyle(() => ({ 104 + transform: [ 105 + { 106 + translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { 107 + extrapolateRight: Extrapolation.CLAMP, 108 + }), 109 + }, 110 + ], 111 + })) 112 + 113 + return ( 114 + <> 115 + <Animated.View 116 + style={[ 117 + a.absolute, 118 + {left: 0, right: 0, bottom: 0}, 119 + {height: 150}, 120 + {transformOrigin: 'bottom'}, 121 + animatedStyle, 122 + ]}> 123 + {children} 124 + <AnimatedBlurView 125 + style={[a.absolute, a.inset_0]} 126 + tint="dark" 127 + animatedProps={animatedBlurViewProps} 128 + /> 129 + </Animated.View> 130 + <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 131 + <Animated.View style={[animatedSpinnerStyle]}> 132 + <ActivityIndicator 133 + key={animateSpinner ? 'spin' : 'stop'} 134 + size="large" 135 + color="white" 136 + animating={animateSpinner} 137 + hidesWhenStopped={false} 138 + /> 139 + </Animated.View> 140 + </View> 141 + <Animated.View style={[animatedBackButtonStyle]}> 142 + {backButton} 143 + </Animated.View> 144 + </> 145 + ) 146 + } 147 + 148 + function useIsProfileFetching() { 149 + // are any of the profile-related queries fetching? 150 + return [ 151 + useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), 152 + useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), 153 + useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), 154 + useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), 155 + ].some(isFetching => isFetching) 156 + } 157 + 158 + function useShouldAnimateSpinner({ 159 + isFetching, 160 + scrollY, 161 + }: { 162 + isFetching: boolean 163 + scrollY: SharedValue<number> 164 + }) { 165 + const [isOverscrolled, setIsOverscrolled] = useState(false) 166 + // HACK: it reports a scroll pos of 0 for a tick when fetching finishes 167 + // so paper over that by keeping it true for a bit -sfn 168 + const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) 169 + 170 + useAnimatedReaction( 171 + () => scrollY.value < -5, 172 + (value, prevValue) => { 173 + if (value !== prevValue) { 174 + runOnJS(setIsOverscrolled)(value) 175 + } 176 + }, 177 + [scrollY], 178 + ) 179 + 180 + const [isAnimating, setIsAnimating] = useState(isFetching) 181 + 182 + if (isFetching && !isAnimating) { 183 + setIsAnimating(true) 184 + } 185 + 186 + if (!isFetching && isAnimating && !stickyIsOverscrolled) { 187 + setIsAnimating(false) 188 + } 189 + 190 + return isAnimating 191 + } 192 + 193 + // stayed true for at least `delay` ms before returning to false 194 + function useStickyToggle(value: boolean, delay: number) { 195 + const [prevValue, setPrevValue] = useState(value) 196 + const [isSticking, setIsSticking] = useState(false) 197 + 198 + useEffect(() => { 199 + if (isSticking) { 200 + const timeout = setTimeout(() => setIsSticking(false), delay) 201 + return () => clearTimeout(timeout) 202 + } 203 + }, [isSticking, delay]) 204 + 205 + if (value !== prevValue) { 206 + setIsSticking(prevValue) // Going true -> false should stick. 207 + setPrevValue(value) 208 + return prevValue ? true : value 209 + } 210 + 211 + return isSticking ? true : value 212 + }
+50 -34
src/screens/Profile/Header/Shell.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 import {useNavigation} from '@react-navigation/native' 8 8 9 + import {BACK_HITSLOP} from '#/lib/constants' 10 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 + import {NavigationProp} from '#/lib/routes/types' 12 + import {isIOS} from '#/platform/detection' 9 13 import {Shadow} from '#/state/cache/types' 10 14 import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox' 11 15 import {useSession} from '#/state/session' 12 - import {BACK_HITSLOP} from 'lib/constants' 13 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 14 - import {NavigationProp} from 'lib/routes/types' 15 - import {isIOS} from 'platform/detection' 16 - import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 17 - import {UserAvatar} from 'view/com/util/UserAvatar' 18 - import {UserBanner} from 'view/com/util/UserBanner' 16 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 17 + import {UserAvatar} from '#/view/com/util/UserAvatar' 18 + import {UserBanner} from '#/view/com/util/UserBanner' 19 19 import {atoms as a, useTheme} from '#/alf' 20 20 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 21 21 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 22 + import {GrowableBanner} from './GrowableBanner' 22 23 23 24 interface Props { 24 25 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> ··· 63 64 64 65 return ( 65 66 <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> 66 - <View pointerEvents={isIOS ? 'auto' : 'none'}> 67 - {isPlaceholderProfile ? ( 68 - <LoadingPlaceholder 69 - width="100%" 70 - height={150} 71 - style={{borderRadius: 0}} 72 - /> 73 - ) : ( 74 - <UserBanner 75 - type={profile.associated?.labeler ? 'labeler' : 'default'} 76 - banner={profile.banner} 77 - moderation={moderation.ui('banner')} 78 - /> 79 - )} 67 + <View 68 + pointerEvents={isIOS ? 'auto' : 'none'} 69 + style={[a.relative, {height: 150}]}> 70 + <GrowableBanner 71 + backButton={ 72 + <> 73 + {!isDesktop && !hideBackButton && ( 74 + <TouchableWithoutFeedback 75 + testID="profileHeaderBackBtn" 76 + onPress={onPressBack} 77 + hitSlop={BACK_HITSLOP} 78 + accessibilityRole="button" 79 + accessibilityLabel={_(msg`Back`)} 80 + accessibilityHint=""> 81 + <View style={styles.backBtnWrapper}> 82 + <FontAwesomeIcon 83 + size={18} 84 + icon="angle-left" 85 + color="white" 86 + /> 87 + </View> 88 + </TouchableWithoutFeedback> 89 + )} 90 + </> 91 + }> 92 + {isPlaceholderProfile ? ( 93 + <LoadingPlaceholder 94 + width="100%" 95 + height="100%" 96 + style={{borderRadius: 0}} 97 + /> 98 + ) : ( 99 + <UserBanner 100 + type={profile.associated?.labeler ? 'labeler' : 'default'} 101 + banner={profile.banner} 102 + moderation={moderation.ui('banner')} 103 + /> 104 + )} 105 + </GrowableBanner> 80 106 </View> 81 107 82 108 {children} ··· 93 119 </View> 94 120 )} 95 121 96 - {!isDesktop && !hideBackButton && ( 97 - <TouchableWithoutFeedback 98 - testID="profileHeaderBackBtn" 99 - onPress={onPressBack} 100 - hitSlop={BACK_HITSLOP} 101 - accessibilityRole="button" 102 - accessibilityLabel={_(msg`Back`)} 103 - accessibilityHint=""> 104 - <View style={styles.backBtnWrapper}> 105 - <FontAwesomeIcon size={18} icon="angle-left" color="white" /> 106 - </View> 107 - </TouchableWithoutFeedback> 108 - )} 109 122 <TouchableWithoutFeedback 110 123 testID="profileHeaderAviButton" 111 124 onPress={onPressAvi} ··· 144 157 borderRadius: 15, 145 158 // @ts-ignore web only 146 159 cursor: 'pointer', 160 + backgroundColor: 'rgba(0, 0, 0, 0.5)', 161 + alignItems: 'center', 162 + justifyContent: 'center', 147 163 }, 148 164 backBtn: { 149 165 width: 30,
+2 -2
src/state/queries/actor-starter-packs.ts
··· 6 6 useInfiniteQuery, 7 7 } from '@tanstack/react-query' 8 8 9 - import {useAgent} from 'state/session' 9 + import {useAgent} from '#/state/session' 10 10 11 - const RQKEY_ROOT = 'actor-starter-packs' 11 + export const RQKEY_ROOT = 'actor-starter-packs' 12 12 export const RQKEY = (did?: string) => [RQKEY_ROOT, did] 13 13 14 14 export function useActorStarterPacksQuery({did}: {did?: string}) {
+10 -10
src/state/queries/post-feed.ts
··· 15 15 useInfiniteQuery, 16 16 } from '@tanstack/react-query' 17 17 18 + import {AuthorFeedAPI} from '#/lib/api/feed/author' 19 + import {CustomFeedAPI} from '#/lib/api/feed/custom' 20 + import {FollowingFeedAPI} from '#/lib/api/feed/following' 18 21 import {HomeFeedAPI} from '#/lib/api/feed/home' 22 + import {LikesFeedAPI} from '#/lib/api/feed/likes' 23 + import {ListFeedAPI} from '#/lib/api/feed/list' 24 + import {MergeFeedAPI} from '#/lib/api/feed/merge' 25 + import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types' 19 26 import {aggregateUserInterests} from '#/lib/api/feed/utils' 27 + import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip' 20 28 import {DISCOVER_FEED_URI} from '#/lib/constants' 29 + import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' 21 30 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 22 31 import {logger} from '#/logger' 23 32 import {STALE} from '#/state/queries' 24 33 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 25 34 import {useAgent} from '#/state/session' 26 35 import * as userActionHistory from '#/state/userActionHistory' 27 - import {AuthorFeedAPI} from 'lib/api/feed/author' 28 - import {CustomFeedAPI} from 'lib/api/feed/custom' 29 - import {FollowingFeedAPI} from 'lib/api/feed/following' 30 - import {LikesFeedAPI} from 'lib/api/feed/likes' 31 - import {ListFeedAPI} from 'lib/api/feed/list' 32 - import {MergeFeedAPI} from 'lib/api/feed/merge' 33 - import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' 34 - import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip' 35 - import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' 36 36 import {KnownError} from '#/view/com/posts/FeedErrorMessage' 37 37 import {useFeedTuners} from '../preferences/feed-tuners' 38 38 import {useModerationOpts} from '../preferences/moderation-opts' ··· 65 65 66 66 type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined 67 67 68 - const RQKEY_ROOT = 'post-feed' 68 + export const RQKEY_ROOT = 'post-feed' 69 69 export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { 70 70 return [RQKEY_ROOT, feedDesc, params || {}] 71 71 }
+1 -1
src/state/queries/profile-feedgens.ts
··· 8 8 type RQPageParam = string | undefined 9 9 10 10 // TODO refactor invalidate on mutate? 11 - const RQKEY_ROOT = 'profile-feedgens' 11 + export const RQKEY_ROOT = 'profile-feedgens' 12 12 export const RQKEY = (did: string) => [RQKEY_ROOT, did] 13 13 14 14 export function useProfileFeedgensQuery(
+1 -1
src/state/queries/profile-lists.ts
··· 7 7 const PAGE_SIZE = 30 8 8 type RQPageParam = string | undefined 9 9 10 - const RQKEY_ROOT = 'profile-lists' 10 + export const RQKEY_ROOT = 'profile-lists' 11 11 export const RQKEY = (did: string) => [RQKEY_ROOT, did] 12 12 13 13 export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
+41
src/view/com/pager/PagerHeaderContext.tsx
··· 1 + import React, {useContext} from 'react' 2 + import {SharedValue} from 'react-native-reanimated' 3 + 4 + import {isIOS} from '#/platform/detection' 5 + 6 + export const PagerHeaderContext = 7 + React.createContext<SharedValue<number> | null>(null) 8 + 9 + /** 10 + * Passes the scrollY value to the pager header's banner, so it can grow on 11 + * overscroll on iOS. Not necessary to use this context provider on other platforms. 12 + * 13 + * @platform ios 14 + */ 15 + export function PagerHeaderProvider({ 16 + scrollY, 17 + children, 18 + }: { 19 + scrollY: SharedValue<number> 20 + children: React.ReactNode 21 + }) { 22 + return ( 23 + <PagerHeaderContext.Provider value={scrollY}> 24 + {children} 25 + </PagerHeaderContext.Provider> 26 + ) 27 + } 28 + 29 + export function usePagerHeaderContext() { 30 + const scrollY = useContext(PagerHeaderContext) 31 + if (isIOS) { 32 + if (!scrollY) { 33 + throw new Error( 34 + 'usePagerHeaderContext must be used within a HeaderProvider', 35 + ) 36 + } 37 + return {scrollY} 38 + } else { 39 + return null 40 + } 41 + }
+17 -14
src/view/com/pager/PagerWithHeader.tsx
··· 22 22 import {isIOS} from '#/platform/detection' 23 23 import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' 24 24 import {ListMethods} from '../util/List' 25 + import {PagerHeaderProvider} from './PagerHeaderContext' 25 26 import {TabBar} from './TabBar' 26 27 27 28 export interface PagerWithHeaderChildParams { ··· 82 83 const renderTabBar = React.useCallback( 83 84 (props: RenderTabBarFnProps) => { 84 85 return ( 85 - <PagerTabBar 86 - headerOnlyHeight={headerOnlyHeight} 87 - items={items} 88 - isHeaderReady={isHeaderReady} 89 - renderHeader={renderHeader} 90 - currentPage={currentPage} 91 - onCurrentPageSelected={onCurrentPageSelected} 92 - onTabBarLayout={onTabBarLayout} 93 - onHeaderOnlyLayout={onHeaderOnlyLayout} 94 - onSelect={props.onSelect} 95 - scrollY={scrollY} 96 - testID={testID} 97 - allowHeaderOverScroll={allowHeaderOverScroll} 98 - /> 86 + <PagerHeaderProvider scrollY={scrollY}> 87 + <PagerTabBar 88 + headerOnlyHeight={headerOnlyHeight} 89 + items={items} 90 + isHeaderReady={isHeaderReady} 91 + renderHeader={renderHeader} 92 + currentPage={currentPage} 93 + onCurrentPageSelected={onCurrentPageSelected} 94 + onTabBarLayout={onTabBarLayout} 95 + onHeaderOnlyLayout={onHeaderOnlyLayout} 96 + onSelect={props.onSelect} 97 + scrollY={scrollY} 98 + testID={testID} 99 + allowHeaderOverScroll={allowHeaderOverScroll} 100 + /> 101 + </PagerHeaderProvider> 99 102 ) 100 103 }, 101 104 [
+1 -1
src/view/com/util/UserBanner.tsx
··· 202 202 }, 203 203 bannerImage: { 204 204 width: '100%', 205 - height: 150, 205 + height: '100%', 206 206 }, 207 207 defaultBanner: { 208 208 backgroundColor: '#0070ff',
+5
yarn.lock
··· 12165 12165 invariant "^2.2.4" 12166 12166 md5-file "^3.2.3" 12167 12167 12168 + expo-blur@^13.0.2: 12169 + version "13.0.2" 12170 + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958" 12171 + integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w== 12172 + 12168 12173 expo-build-properties@^0.12.1: 12169 12174 version "0.12.1" 12170 12175 resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"