Compare commits

...

4 Commits

Author SHA1 Message Date
Samuel Newman c1bf738740 only move down tab bar, leave buttons 2024-09-26 13:58:03 +01:00
Samuel Newman b3ff651663 justify center 2024-09-26 02:11:30 +01:00
Samuel Newman ed1ad25cea scroll animation on logo 2024-09-26 00:48:45 +01:00
Samuel Newman ff9be546e7 split out scroll hooks 2024-09-26 00:18:15 +01:00
9 changed files with 271 additions and 100 deletions
+32
View File
@@ -0,0 +1,32 @@
import {useState} from 'react'
import {
runOnJS,
SharedValue,
useAnimatedReaction,
} from 'react-native-reanimated'
import {useStickyToggle} from './useStickyToggle'
export function useOverscrolled({
scrollY,
threshold = -5,
}: {
scrollY?: SharedValue<number>
threshold?: 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 ? scrollY.value < threshold : false),
(value, prevValue) => {
if (value !== prevValue) {
runOnJS(setIsOverscrolled)(value)
}
},
)
return stickyIsOverscrolled
}
+22
View File
@@ -0,0 +1,22 @@
import {useEffect, useState} from 'react'
// stayed true for at least `delay` ms before returning to false
export 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
}
+7 -43
View File
@@ -1,13 +1,11 @@
import React, {useEffect, useState} from 'react'
import React, {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'
@@ -20,6 +18,7 @@ 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'
import {useOverscrolled} from '#/components/hooks/useOverscrolled'
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
@@ -61,7 +60,7 @@ function GrowableBannerInner({
children: React.ReactNode
}) {
const isFetching = useIsProfileFetching()
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
const animateSpinner = useFetchingWhenOverscrolled({isFetching, scrollY})
const animatedStyle = useAnimatedStyle(() => ({
transform: [
@@ -155,58 +154,23 @@ function useIsProfileFetching() {
].some(isFetching => isFetching)
}
function useShouldAnimateSpinner({
export function useFetchingWhenOverscrolled({
isFetching,
scrollY,
}: {
isFetching: boolean
scrollY: SharedValue<number>
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 isOverscrolled = useOverscrolled({scrollY})
const [isAnimating, setIsAnimating] = useState(isFetching)
if (isFetching && !isAnimating) {
setIsAnimating(true)
}
if (!isFetching && isAnimating && !stickyIsOverscrolled) {
if (!isFetching && isAnimating && !isOverscrolled) {
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
}
+8 -6
View File
@@ -1,13 +1,17 @@
import React from 'react'
import {View} from 'react-native'
import {NativeScrollEvent, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NavigationProp, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {ComposeIcon2} from '#/lib/icons'
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
import {AllNavigatorParams} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
@@ -17,10 +21,6 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons'
import {AllNavigatorParams} from 'lib/routes/types'
import {s} from 'lib/styles'
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import {Feed} from '../posts/Feed'
import {FAB} from '../util/fab/FAB'
@@ -38,6 +38,7 @@ export function FeedPage({
renderEmptyState,
renderEndOfFeed,
savedFeedConfig,
onScroll,
}: {
testID?: string
feed: FeedDescriptor
@@ -46,6 +47,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
savedFeedConfig?: AppBskyActorDefs.SavedFeed
onScroll: (e: NativeScrollEvent) => void
}) {
const {hasSession} = useSession()
const {_} = useLingui()
@@ -111,7 +113,7 @@ export function FeedPage({
return (
<View testID={testID} style={s.h100pct}>
<MainScrollProvider>
<MainScrollProvider onScroll={onScroll}>
<FeedFeedbackProvider value={feedFeedback}>
<Feed
testID={testID ? `${testID}-feed` : undefined}
+6 -4
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {SharedValue} from 'react-native-reanimated'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from '#/lib/hooks/usePalette'
import {NavigationProp} from '#/lib/routes/types'
import {FeedSourceInfo} from '#/state/queries/feed'
import {useSession} from '#/state/session'
import {NavigationProp} from 'lib/routes/types'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {TabBar} from '../pager/TabBar'
import {HomeHeaderLayout} from './HomeHeaderLayout'
@@ -14,9 +15,10 @@ export function HomeHeader(
testID?: string
onPressSelected: () => void
feeds: FeedSourceInfo[]
scrollY: SharedValue<number>
},
) {
const {feeds} = props
const {feeds, scrollY} = props
const {hasSession} = useSession()
const navigation = useNavigation<NavigationProp>()
const pal = usePalette('default')
@@ -53,7 +55,7 @@ export function HomeHeader(
)
return (
<HomeHeaderLayout tabBarAnchor={props.tabBarAnchor}>
<HomeHeaderLayout tabBarAnchor={props.tabBarAnchor} scrollY={scrollY}>
<TabBar
key={items.join(',')}
onPressSelected={props.onPressSelected}
+2 -2
View File
@@ -13,10 +13,10 @@ import Animated, {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
// import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
+149 -32
View File
@@ -1,6 +1,15 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
LayoutAnimationConfig,
SharedValue,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
@@ -9,22 +18,23 @@ import Animated, {
withSequence,
withSpring,
withTiming,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {useShellLayout} from '#/state/shell/shell-layout'
import {HITSLOP_10} from 'lib/constants'
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
// import {Logo} from '#/view/icons/Logo'
import {atoms} from '#/alf'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {atoms as a, native, useTheme} from '#/alf'
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
import {useOverscrolled} from '#/components/hooks/useOverscrolled'
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
@@ -33,17 +43,20 @@ import {IS_DEV} from '#/env'
export function HomeHeaderLayoutMobile({
children,
scrollY,
}: {
children: React.ReactNode
tabBarAnchor: JSX.Element | null | undefined
scrollY?: SharedValue<number>
}) {
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const {headerHeight} = useShellLayout()
const {height: frameHeight} = useSafeAreaFrame()
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession()
const showSpinner = useShowSpinner({scrollY})
const onPressAvi = React.useCallback(() => {
setDrawerOpen(true)
@@ -78,7 +91,37 @@ export function HomeHeaderLayoutMobile({
)
}, [rotate, reducedMotion])
const animatedStyle = useAnimatedStyle(() => ({
const animatedContainerStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: isWeb
? 0
: interpolate(
scrollY?.value ?? -headerHeight.value,
[-headerHeight.value, 0],
[headerHeight.value, 0],
{extrapolateRight: Extrapolation.CLAMP},
),
},
],
}))
const animatedLogoContainerStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: isWeb
? 0
: interpolate(
scrollY?.value ?? -headerHeight.value,
[-headerHeight.value, 0],
[headerHeight.value / 2, 0],
{extrapolateRight: Extrapolation.CLAMP},
),
},
],
}))
const animatedJiggleStyle = useAnimatedStyle(() => ({
transform: [
{
rotateZ: `${rotate.value}deg`,
@@ -86,14 +129,21 @@ export function HomeHeaderLayoutMobile({
],
}))
console.log(headerHeight.value)
return (
<Animated.View
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.tabBar,
headerMinimalShellTransform,
]}
onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height
}}>
<View style={[pal.view, styles.topBar]}>
<View style={[pal.view, {width: 100}]}>
<View style={[styles.topBar, a.z_10]}>
<View style={[{width: 100}]}>
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={onPressAvi}
@@ -106,26 +156,60 @@ export function HomeHeaderLayoutMobile({
<Menu size="lg" fill={t.atoms.text_contrast_medium.color} />
</TouchableOpacity>
</View>
<Animated.View style={animatedStyle}>
<Trigger>
{ctx => (
<Icon
width={28}
style={{
opacity: ctx.pressed ? 0.8 : 1,
}}
/>
<Animated.View style={[animatedLogoContainerStyle]}>
<LayoutAnimationConfig skipEntering skipExiting>
{showSpinner ? (
<Animated.View
key={1}
entering={ZoomIn.delay(200)}
exiting={ZoomOut}
pointerEvents="none"
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
]}>
<ActivityIndicator
size="small"
color={t.atoms.text_contrast_medium.color}
/>
</Animated.View>
) : (
<Animated.View
key={2}
entering={ZoomIn.delay(300)}
exiting={ZoomOut}
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
]}>
<Animated.View style={[animatedJiggleStyle]}>
<Trigger>
{ctx => (
<Icon
width={28}
style={{
opacity: ctx.pressed ? 0.8 : 1,
}}
/>
)}
</Trigger>
</Animated.View>
{/* <Logo width={30} /> */}
</Animated.View>
)}
</Trigger>
{/* <Logo width={30} /> */}
</LayoutAnimationConfig>
</Animated.View>
<View
style={[
atoms.flex_row,
atoms.justify_end,
atoms.align_center,
atoms.gap_md,
pal.view,
a.flex_row,
a.justify_end,
a.align_center,
a.gap_md,
t.atoms.bg,
{width: 100},
]}>
{IS_DEV && (
@@ -157,7 +241,19 @@ export function HomeHeaderLayoutMobile({
)}
</View>
</View>
{children}
<Animated.View style={[a.relative, animatedContainerStyle]}>
{native(
<View
style={[
a.absolute,
{height: frameHeight},
{bottom: '100%', left: 0, right: 0},
t.atoms.bg,
]}
/>,
)}
{children}
</Animated.View>
</Animated.View>
)
}
@@ -170,7 +266,6 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
top: 0,
flexDirection: 'column',
},
topBar: {
flexDirection: 'row',
@@ -185,3 +280,25 @@ const styles = StyleSheet.create({
fontSize: 21,
},
})
function useShowSpinner({scrollY}: {scrollY?: SharedValue<number>}) {
console.log(scrollY?.value)
const isOverscrolled = useOverscrolled({scrollY, threshold: -15})
const isSignificantlyOverscrolled = useOverscrolled({scrollY, threshold: -65})
const [
hasBeenSignificantlyOverscrolled,
setHasBeenSignificantlyOverscrolled,
] = useState(isSignificantlyOverscrolled)
if (isSignificantlyOverscrolled && !hasBeenSignificantlyOverscrolled) {
setHasBeenSignificantlyOverscrolled(true)
} else if (
!isOverscrolled &&
!isSignificantlyOverscrolled &&
hasBeenSignificantlyOverscrolled
) {
setHasBeenSignificantlyOverscrolled(false)
}
return isOverscrolled && hasBeenSignificantlyOverscrolled
}
+11 -2
View File
@@ -9,9 +9,9 @@ import {
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {isNative, isWeb} from '#/platform/detection'
import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
const WEB_HIDE_SHELL_THRESHOLD = 200
@@ -20,7 +20,13 @@ function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}
export function MainScrollProvider({children}: {children: React.ReactNode}) {
export function MainScrollProvider({
children,
onScroll: onScrollProp,
}: {
children: React.ReactNode
onScroll?: (e: NativeScrollEvent) => void
}) {
const {headerHeight} = useShellLayout()
const {headerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
@@ -112,6 +118,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
if (onScrollProp) onScrollProp(e)
if (startDragOffset.value === null || startMode.value === null) {
if (
headerMode.value !== 0 &&
@@ -164,6 +172,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
startDragOffset,
startMode,
didJustRestoreScroll,
onScrollProp,
],
)
+34 -11
View File
@@ -1,10 +1,22 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {
ActivityIndicator,
NativeScrollEvent,
StyleSheet,
View,
} from 'react-native'
import {useSharedValue} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {
HomeTabNavigatorParams,
NativeStackScreenProps,
} from '#/lib/routes/types'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
@@ -14,16 +26,13 @@ import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {FeedPage} from 'view/com/feeds/FeedPage'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
import {FeedPage} from '#/view/com/feeds/FeedPage'
import {HomeHeader} from '#/view/com/home/HomeHeader'
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {CustomFeedEmptyState} from '#/view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from '#/view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed'
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
import {HomeHeader} from '../com/home/HomeHeader'
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
export function HomeScreen(props: Props) {
@@ -81,6 +90,7 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
const scrollY = useSharedValue(0)
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -176,10 +186,11 @@ function HomeScreenReady({
testID="homeScreenFeedTabs"
onPressSelected={onPressSelected}
feeds={pinnedFeedInfos}
scrollY={scrollY}
/>
)
},
[onPressSelected, pinnedFeedInfos],
[onPressSelected, pinnedFeedInfos, scrollY],
)
const renderFollowingEmptyState = React.useCallback(() => {
@@ -201,6 +212,15 @@ function HomeScreenReady({
}
}, [preferences])
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
scrollY.value = nextScrollY
},
[scrollY],
)
return hasSession ? (
<Pager
key={allFeeds.join(',')}
@@ -224,6 +244,7 @@ function HomeScreenReady({
feedParams={homeFeedParams}
renderEmptyState={renderFollowingEmptyState}
renderEndOfFeed={FollowingEndOfFeed}
onScroll={onScrollWorklet}
/>
)
}
@@ -236,6 +257,7 @@ function HomeScreenReady({
feed={feed}
renderEmptyState={renderCustomFeedEmptyState}
savedFeedConfig={savedFeedConfig}
onScroll={onScrollWorklet}
/>
)
})
@@ -254,6 +276,7 @@ function HomeScreenReady({
isPageFocused
feed={`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`}
renderEmptyState={renderCustomFeedEmptyState}
onScroll={onScrollWorklet}
/>
</Pager>
)