about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-08-22 23:27:33 +0100
committerGitHub <noreply@github.com>2024-08-22 23:27:33 +0100
commitb8dbb71781997c9b8d595e7760f99b30a5e199e5 (patch)
tree308a70dc5c9e2441319009cd8144c4c2d00113b9
parent2ae3ffcf782e10bddcf1fdbbc3983724f605e711 (diff)
downloadvoidsky-b8dbb71781997c9b8d595e7760f99b30a5e199e5.tar.zst
Fix fixed footer experiment (#4969)
* Split minimal shell mode into headerMode and footerMode

For now, we'll always write them in sync. When we read them, we'll use headerMode as source of truth. This will let us keep footerMode independent in a future commit.

* Remove fixed_bottom_bar special cases during calculation

This isn't the right time to determine special behavior. Instead we'll adjust footerMode itself conditionally on the gate.

* Copy-paste setMode into MainScrollProvider

This lets us fork the implementation later just for this case.

* Gate footer adjustment in MainScrollProvider

This is the final piece. Normal calls to setMode() keep setting both header and footer, but MainScrollProvider adjusts the footer conditionally.
-rw-r--r--src/lib/hooks/useMinimalShellTransform.ts45
-rw-r--r--src/state/shell/minimal-mode.tsx43
-rw-r--r--src/view/com/util/MainScrollProvider.tsx53
-rw-r--r--src/view/screens/Home.tsx6
4 files changed, 95 insertions, 52 deletions
diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts
index 17fe058e9..678776755 100644
--- a/src/lib/hooks/useMinimalShellTransform.ts
+++ b/src/lib/hooks/useMinimalShellTransform.ts
@@ -2,21 +2,24 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
 
 import {useMinimalShellMode} from '#/state/shell/minimal-mode'
 import {useShellLayout} from '#/state/shell/shell-layout'
-import {useGate} from '../statsig/statsig'
 
 // Keep these separated so that we only pay for useAnimatedStyle that gets used.
 
 export function useMinimalShellHeaderTransform() {
-  const mode = useMinimalShellMode()
+  const {headerMode} = useMinimalShellMode()
   const {headerHeight} = useShellLayout()
 
   const headerTransform = useAnimatedStyle(() => {
     return {
-      pointerEvents: mode.value === 0 ? 'auto' : 'none',
-      opacity: Math.pow(1 - mode.value, 2),
+      pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - headerMode.value, 2),
       transform: [
         {
-          translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
+          translateY: interpolate(
+            headerMode.value,
+            [0, 1],
+            [0, -headerHeight.value],
+          ),
         },
       ],
     }
@@ -26,21 +29,20 @@ export function useMinimalShellHeaderTransform() {
 }
 
 export function useMinimalShellFooterTransform() {
-  const mode = useMinimalShellMode()
+  const {footerMode} = useMinimalShellMode()
   const {footerHeight} = useShellLayout()
-  const gate = useGate()
-  const isFixedBottomBar = gate('fixed_bottom_bar')
 
   const footerTransform = useAnimatedStyle(() => {
-    if (isFixedBottomBar) {
-      return {}
-    }
     return {
-      pointerEvents: mode.value === 0 ? 'auto' : 'none',
-      opacity: Math.pow(1 - mode.value, 2),
+      pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
+      opacity: Math.pow(1 - footerMode.value, 2),
       transform: [
         {
-          translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
+          translateY: interpolate(
+            footerMode.value,
+            [0, 1],
+            [0, footerHeight.value],
+          ),
         },
       ],
     }
@@ -50,24 +52,13 @@ export function useMinimalShellFooterTransform() {
 }
 
 export function useMinimalShellFabTransform() {
-  const mode = useMinimalShellMode()
-  const gate = useGate()
-  const isFixedBottomBar = gate('fixed_bottom_bar')
+  const {footerMode} = useMinimalShellMode()
 
   const fabTransform = useAnimatedStyle(() => {
-    if (isFixedBottomBar) {
-      return {
-        transform: [
-          {
-            translateY: -44,
-          },
-        ],
-      }
-    }
     return {
       transform: [
         {
-          translateY: interpolate(mode.value, [0, 1], [-44, 0]),
+          translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
         },
       ],
     }
diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx
index 69ce13062..9230339dd 100644
--- a/src/state/shell/minimal-mode.tsx
+++ b/src/state/shell/minimal-mode.tsx
@@ -6,32 +6,55 @@ import {
   withSpring,
 } from 'react-native-reanimated'
 
-type StateContext = SharedValue<number>
+type StateContext = {
+  headerMode: SharedValue<number>
+  footerMode: SharedValue<number>
+}
 type SetContext = (v: boolean) => void
 
 const stateContext = React.createContext<StateContext>({
-  value: 0,
-  addListener() {},
-  removeListener() {},
-  modify() {},
+  headerMode: {
+    value: 0,
+    addListener() {},
+    removeListener() {},
+    modify() {},
+  },
+  footerMode: {
+    value: 0,
+    addListener() {},
+    removeListener() {},
+    modify() {},
+  },
 })
 const setContext = React.createContext<SetContext>((_: boolean) => {})
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const mode = useSharedValue(0)
+  const headerMode = useSharedValue(0)
+  const footerMode = useSharedValue(0)
   const setMode = React.useCallback(
     (v: boolean) => {
       'worklet'
       // Cancel any existing animation
-      cancelAnimation(mode)
-      mode.value = withSpring(v ? 1 : 0, {
+      cancelAnimation(headerMode)
+      headerMode.value = withSpring(v ? 1 : 0, {
+        overshootClamping: true,
+      })
+      cancelAnimation(footerMode)
+      footerMode.value = withSpring(v ? 1 : 0, {
         overshootClamping: true,
       })
     },
-    [mode],
+    [headerMode, footerMode],
+  )
+  const value = React.useMemo(
+    () => ({
+      headerMode,
+      footerMode,
+    }),
+    [headerMode, footerMode],
   )
   return (
-    <stateContext.Provider value={mode}>
+    <stateContext.Provider value={value}>
       <setContext.Provider value={setMode}>{children}</setContext.Provider>
     </stateContext.Provider>
   )
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index b602da432..3163d8544 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -4,11 +4,13 @@ import {
   cancelAnimation,
   interpolate,
   useSharedValue,
+  withSpring,
 } from 'react-native-reanimated'
 import EventEmitter from 'eventemitter3'
 
 import {ScrollProvider} from '#/lib/ScrollContext'
-import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
+import {useGate} from '#/lib/statsig/statsig'
+import {useMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
 import {isNative, isWeb} from 'platform/detection'
 
@@ -21,11 +23,29 @@ function clamp(num: number, min: number, max: number) {
 
 export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const {headerHeight} = useShellLayout()
-  const mode = useMinimalShellMode()
-  const setMode = useSetMinimalShellMode()
+  const {headerMode, footerMode} = useMinimalShellMode()
   const startDragOffset = useSharedValue<number | null>(null)
   const startMode = useSharedValue<number | null>(null)
   const didJustRestoreScroll = useSharedValue<boolean>(false)
+  const gate = useGate()
+  const isFixedBottomBar = gate('fixed_bottom_bar')
+
+  const setMode = React.useCallback(
+    (v: boolean) => {
+      'worklet'
+      cancelAnimation(headerMode)
+      headerMode.value = withSpring(v ? 1 : 0, {
+        overshootClamping: true,
+      })
+      if (!isFixedBottomBar) {
+        cancelAnimation(footerMode)
+        footerMode.value = withSpring(v ? 1 : 0, {
+          overshootClamping: true,
+        })
+      }
+    },
+    [headerMode, footerMode, isFixedBottomBar],
+  )
 
   useEffect(() => {
     if (isWeb) {
@@ -55,11 +75,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
           setMode(true)
         } else {
           // Snap to whichever state is the closest.
-          setMode(Math.round(mode.value) === 1)
+          setMode(Math.round(headerMode.value) === 1)
         }
       }
     },
-    [startDragOffset, startMode, setMode, mode, headerHeight],
+    [startDragOffset, startMode, setMode, headerMode, headerHeight],
   )
 
   const onBeginDrag = useCallback(
@@ -67,10 +87,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
       'worklet'
       if (isNative) {
         startDragOffset.value = e.contentOffset.y
-        startMode.value = mode.value
+        startMode.value = headerMode.value
       }
     },
-    [mode, startDragOffset, startMode],
+    [headerMode, startDragOffset, startMode],
   )
 
   const onEndDrag = useCallback(
@@ -102,7 +122,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
       'worklet'
       if (isNative) {
         if (startDragOffset.value === null || startMode.value === null) {
-          if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+          if (
+            headerMode.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)
@@ -119,11 +142,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
           [-1, 1],
         )
         const newValue = clamp(startMode.value + dProgress, 0, 1)
-        if (newValue !== mode.value) {
+        if (newValue !== headerMode.value) {
           // Manually adjust the value. This won't be (and shouldn't be) animated.
           // Cancel any any existing animation
-          cancelAnimation(mode)
-          mode.value = newValue
+          cancelAnimation(headerMode)
+          headerMode.value = newValue
+          if (!isFixedBottomBar) {
+            cancelAnimation(footerMode)
+            footerMode.value = newValue
+          }
         }
       } else {
         if (didJustRestoreScroll.value) {
@@ -145,11 +172,13 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     },
     [
       headerHeight,
-      mode,
+      headerMode,
+      footerMode,
       setMode,
       startDragOffset,
       startMode,
       didJustRestoreScroll,
+      isFixedBottomBar,
     ],
   )
 
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 9a47007c4..af424428d 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -167,7 +167,7 @@ function HomeScreenReady({
     }),
   )
 
-  const mode = useMinimalShellMode()
+  const {footerMode} = useMinimalShellMode()
   const {isMobile} = useWebMediaQueries()
   useFocusEffect(
     React.useCallback(() => {
@@ -177,7 +177,7 @@ function HomeScreenReady({
       }
       const listener = AppState.addEventListener('change', nextAppState => {
         if (nextAppState === 'active') {
-          if (isMobile && mode.value === 1) {
+          if (isMobile && footerMode.value === 1) {
             // Reveal the bottom bar so you don't miss notifications or messages.
             // TODO: Experiment with only doing it when unread > 0.
             setMinimalShellMode(false)
@@ -187,7 +187,7 @@ function HomeScreenReady({
       return () => {
         listener.remove()
       }
-    }, [setMinimalShellMode, mode, isMobile, gate]),
+    }, [setMinimalShellMode, footerMode, isMobile, gate]),
   )
 
   const onPageSelected = React.useCallback(