diff options
author | dan <dan.abramov@gmail.com> | 2023-11-09 20:15:05 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-09 12:15:05 -0800 |
commit | 7a55ca613347680cd94add01faa5dc3f216b9bd2 (patch) | |
tree | d9fa7951b43a8148b44e12a93452e214e1c4a798 /src | |
parent | 1dcf882619bc2d6b3eefebf83e76f4b21871b791 (diff) | |
download | voidsky-7a55ca613347680cd94add01faa5dc3f216b9bd2.tar.zst |
Sync top/bottom bar disappearance to the scroll (#1855)
* Disable existing code that toggles shell * Make shell mode a float * Translate based on the gesture * Track header and footer heights * Add web support * Fix types and cleanup * Add back isScrolled logic * Add comments
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/hooks/useMinimalShellMode.tsx | 40 | ||||
-rw-r--r-- | src/lib/hooks/useOnMainScroll.ts | 135 | ||||
-rw-r--r-- | src/state/shell/index.tsx | 21 | ||||
-rw-r--r-- | src/state/shell/minimal-mode.tsx | 19 | ||||
-rw-r--r-- | src/state/shell/shell-layout.tsx | 41 | ||||
-rw-r--r-- | src/view/com/feeds/FeedPage.tsx | 2 | ||||
-rw-r--r-- | src/view/com/notifications/Feed.tsx | 2 | ||||
-rw-r--r-- | src/view/com/pager/FeedsTabBar.web.tsx | 7 | ||||
-rw-r--r-- | src/view/com/pager/FeedsTabBarMobile.tsx | 12 | ||||
-rw-r--r-- | src/view/shell/bottom-bar/BottomBar.tsx | 7 |
10 files changed, 181 insertions, 105 deletions
diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index 4738b8e2c..e81fc434f 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,45 +1,29 @@ -import { - AnimatableValue, - interpolate, - useAnimatedStyle, - withTiming, - Easing, -} from 'react-native-reanimated' - +import {interpolate, useAnimatedStyle} from 'react-native-reanimated' import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' - -function withShellTiming<T extends AnimatableValue>(value: T): T { - 'worklet' - return withTiming(value, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) -} +import {useShellLayout} from '#/state/shell/shell-layout' export function useMinimalShellMode() { const mode = useMinimalShellModeState() + const {footerHeight, headerHeight} = useShellLayout() + const footerMinimalShellTransform = useAnimatedStyle(() => { return { - pointerEvents: mode.value ? 'none' : 'auto', - opacity: withShellTiming(interpolate(mode.value ? 1 : 0, [0, 1], [1, 0])), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ { - translateY: withShellTiming( - interpolate(mode.value ? 1 : 0, [0, 1], [0, 25]), - ), + translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]), }, ], } }) const headerMinimalShellTransform = useAnimatedStyle(() => { return { - pointerEvents: mode.value ? 'none' : 'auto', - opacity: withShellTiming(interpolate(mode.value ? 1 : 0, [0, 1], [1, 0])), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ { - translateY: withShellTiming( - interpolate(mode.value ? 1 : 0, [0, 1], [0, -25]), - ), + translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]), }, ], } @@ -48,9 +32,7 @@ export function useMinimalShellMode() { return { transform: [ { - translateY: withShellTiming( - interpolate(mode.value ? 1 : 0, [0, 1], [-44, 0]), - ), + translateY: interpolate(mode.value, [0, 1], [-44, 0]), }, ], } diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index a213d5317..cc07329ea 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,17 +1,19 @@ -import {useState, useCallback, useRef} from 'react' +import {useState, useCallback} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {s} from 'lib/styles' -import {useWebMediaQueries} from './useWebMediaQueries' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useShellLayout} from '#/state/shell/shell-layout' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import { + useAnimatedScrollHandler, + useSharedValue, + interpolate, + runOnJS, +} from 'react-native-reanimated' -const Y_LIMIT = 10 - -const useDeviceLimits = () => { - const {isDesktop} = useWebMediaQueries() - return { - dyLimitUp: isDesktop ? 30 : 10, - dyLimitDown: isDesktop ? 150 : 10, - } +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) } export type OnScrollCb = ( @@ -20,53 +22,82 @@ export type OnScrollCb = ( export type ResetCb = () => void export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { - let lastY = useRef(0) - let [isScrolledDown, setIsScrolledDown] = useState(false) - const {dyLimitUp, dyLimitDown} = useDeviceLimits() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() + const {headerHeight} = useShellLayout() + const [isScrolledDown, setIsScrolledDown] = useState(false) + const mode = useMinimalShellMode() + const setMode = useSetMinimalShellMode() + const startDragOffset = useSharedValue<number | null>(null) + const startMode = useSharedValue<number | null>(null) - return [ - useCallback( - (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY.current || 0) - lastY.current = y + const scrollHandler = useAnimatedScrollHandler({ + onBeginDrag(e) { + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + }, + onEndDrag(e) { + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value / 2) { + // If we're close to the top, show the shell. + setMode(false) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + }, + onScroll(e) { + // Keep track of whether we want to show "scroll to top". + if (!isScrolledDown && e.contentOffset.y > s.window.height) { + runOnJS(setIsScrolledDown)(true) + } else if (isScrolledDown && e.contentOffset.y < s.window.height) { + runOnJS(setIsScrolledDown)(false) + } - if (!minimalShellMode.value && dy > dyLimitDown && y > Y_LIMIT) { - setMinimalShellMode(true) - } else if ( - minimalShellMode.value && - (dy < dyLimitUp * -1 || y <= Y_LIMIT) - ) { - setMinimalShellMode(false) + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + return } - - if ( - !isScrolledDown && - event.nativeEvent.contentOffset.y > s.window.height - ) { - setIsScrolledDown(true) - } else if ( - isScrolledDown && - event.nativeEvent.contentOffset.y < s.window.height - ) { - setIsScrolledDown(false) + if (isWeb) { + // On the web, there is no concept of "starting" the drag. + // When we get the first scroll event, we consider that the start. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value } - }, - [ - dyLimitDown, - dyLimitUp, - isScrolledDown, - minimalShellMode, - setMinimalShellMode, - ], - ), + return + } + + // The "mode" value is always between 0 and 1. + // Figure out how much to move it based on the current dragged distance. + const dy = e.contentOffset.y - startDragOffset.value + const dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + if (isWeb) { + // On the web, there is no concept of "starting" the drag, + // so we don't have any specific anchor point to calculate the distance. + // Instead, update it continuosly along the way and diff with the last event. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + }, + }) + + return [ + scrollHandler, isScrolledDown, useCallback(() => { setIsScrolledDown(false) - setMinimalShellMode(false) - lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf - }, [setIsScrolledDown, setMinimalShellMode]), + setMode(false) + }, [setMode]), ] } diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 6291d3224..eb549b9f9 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {Provider as ShellLayoutProvder} from './shell-layout' import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' @@ -16,14 +17,16 @@ export {useOnboardingState, useOnboardingDispatch} from './onboarding' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - <DrawerOpenProvider> - <DrawerSwipableProvider> - <MinimalModeProvider> - <ColorModeProvider> - <OnboardingProvider>{children}</OnboardingProvider> - </ColorModeProvider> - </MinimalModeProvider> - </DrawerSwipableProvider> - </DrawerOpenProvider> + <ShellLayoutProvder> + <DrawerOpenProvider> + <DrawerSwipableProvider> + <MinimalModeProvider> + <ColorModeProvider> + <OnboardingProvider>{children}</OnboardingProvider> + </ColorModeProvider> + </MinimalModeProvider> + </DrawerSwipableProvider> + </DrawerOpenProvider> + </ShellLayoutProvder> ) } diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx index b506c21db..2c2f60b52 100644 --- a/src/state/shell/minimal-mode.tsx +++ b/src/state/shell/minimal-mode.tsx @@ -1,11 +1,16 @@ import React from 'react' -import {useSharedValue, SharedValue} from 'react-native-reanimated' +import { + Easing, + SharedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated' -type StateContext = SharedValue<boolean> +type StateContext = SharedValue<number> type SetContext = (v: boolean) => void const stateContext = React.createContext<StateContext>({ - value: false, + value: 0, addListener() {}, removeListener() {}, modify() {}, @@ -13,10 +18,14 @@ const stateContext = React.createContext<StateContext>({ const setContext = React.createContext<SetContext>((_: boolean) => {}) export function Provider({children}: React.PropsWithChildren<{}>) { - const mode = useSharedValue(false) + const mode = useSharedValue(0) const setMode = React.useCallback( (v: boolean) => { - mode.value = v + 'worklet' + mode.value = withTiming(v ? 1 : 0, { + duration: 400, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }) }, [mode], ) diff --git a/src/state/shell/shell-layout.tsx b/src/state/shell/shell-layout.tsx new file mode 100644 index 000000000..a58ba851c --- /dev/null +++ b/src/state/shell/shell-layout.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import {SharedValue, useSharedValue} from 'react-native-reanimated' + +type StateContext = { + headerHeight: SharedValue<number> + footerHeight: SharedValue<number> +} + +const stateContext = React.createContext<StateContext>({ + headerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, + footerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const headerHeight = useSharedValue(0) + const footerHeight = useSharedValue(0) + + const value = React.useMemo( + () => ({ + headerHeight, + footerHeight, + }), + [headerHeight, footerHeight], + ) + + return <stateContext.Provider value={value}>{children}</stateContext.Provider> +} + +export function useShellLayout() { + return React.useContext(stateContext) +} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 044f69efe..ffae6cbf4 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -182,7 +182,7 @@ export const FeedPage = observer(function FeedPageImpl({ feed={feed} scrollElRef={scrollElRef} onScroll={onMainScroll} - scrollEventThrottle={100} + scrollEventThrottle={1} renderEmptyState={renderEmptyState} renderEndOfFeed={renderEndOfFeed} ListHeaderComponent={ListHeaderComponent} diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 74769bc76..dff84ec77 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -162,7 +162,7 @@ export const Feed = observer(function Feed({ onEndReached={onEndReached} onEndReachedThreshold={0.6} onScroll={onScroll} - scrollEventThrottle={100} + scrollEventThrottle={1} contentContainerStyle={s.contentContainer} // @ts-ignore our .web version only -prf desktopFixedHeight diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 25755bafe..296af76e4 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -10,6 +10,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useShellLayout} from '#/state/shell/shell-layout' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -31,11 +32,15 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( const items = useHomeTabs(store.preferences.pinnedFeeds) const pal = usePalette('default') const {headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf <Animated.View - style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> + style={[pal.view, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <TabBar key={items.join(',')} {...props} diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 791fe71be..5fda0a991 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -18,6 +18,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useShellLayout} from '#/state/shell/shell-layout' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -28,6 +29,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( const setDrawerOpen = useSetDrawerOpen() const items = useHomeTabs(store.preferences.pinnedFeeds) const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) + const {headerHeight} = useShellLayout() const {headerMinimalShellTransform} = useMinimalShellMode() const onPressAvi = React.useCallback(() => { @@ -36,12 +38,10 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( return ( <Animated.View - style={[ - pal.view, - pal.border, - styles.tabBar, - headerMinimalShellTransform, - ]}> + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 69a7c4c0e..3dd7f57c5 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -27,6 +27,7 @@ import {UserAvatar} from 'view/com/util/UserAvatar' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' +import {useShellLayout} from '#/state/shell/shell-layout' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' @@ -39,6 +40,7 @@ export const BottomBar = observer(function BottomBarImpl({ const {_} = useLingui() const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() + const {footerHeight} = useShellLayout() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() @@ -88,7 +90,10 @@ export const BottomBar = observer(function BottomBarImpl({ pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, footerMinimalShellTransform, - ]}> + ]} + onLayout={e => { + footerHeight.value = e.nativeEvent.layout.height + }}> <Btn testID="bottomBarHomeBtn" icon={ |