about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2023-11-09 20:15:05 +0000
committerGitHub <noreply@github.com>2023-11-09 12:15:05 -0800
commit7a55ca613347680cd94add01faa5dc3f216b9bd2 (patch)
treed9fa7951b43a8148b44e12a93452e214e1c4a798 /src
parent1dcf882619bc2d6b3eefebf83e76f4b21871b791 (diff)
downloadvoidsky-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.tsx40
-rw-r--r--src/lib/hooks/useOnMainScroll.ts135
-rw-r--r--src/state/shell/index.tsx21
-rw-r--r--src/state/shell/minimal-mode.tsx19
-rw-r--r--src/state/shell/shell-layout.tsx41
-rw-r--r--src/view/com/feeds/FeedPage.tsx2
-rw-r--r--src/view/com/notifications/Feed.tsx2
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx7
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx12
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx7
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={