From c1aad1932d2e9982d3c9ca5a7d4f02a289542a9d Mon Sep 17 00:00:00 2001
From: Samuel Holmes <sam@edge.app>
Date: Thu, 11 Jan 2024 16:35:41 -0800
Subject: [PATCH] Add scroll-based animation to HeaderBackground

This required a significant refactor and improvement to global scroll
state handling for each scene and component which uses the
`useSceneScrollHandler` hook.
---
 src/components/common/SceneWrapper.tsx        |   2 +-
 .../navigation/HeaderBackground.tsx           |  27 ++-
 src/state/SceneScrollState.tsx                | 178 +++++++++++++-----
 3 files changed, 152 insertions(+), 55 deletions(-)

diff --git a/src/components/common/SceneWrapper.tsx b/src/components/common/SceneWrapper.tsx
index a4fe542c20a..234f054bff5 100644
--- a/src/components/common/SceneWrapper.tsx
+++ b/src/components/common/SceneWrapper.tsx
@@ -119,7 +119,7 @@ export function SceneWrapper(props: SceneWrapperProps): JSX.Element {
   const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)
 
   // If the scene has scroll, this will be required for tabs and/or header animation
-  const handleScroll = useSceneScrollHandler()
+  const handleScroll = useSceneScrollHandler(scroll && (hasTabs || hasHeader))
 
   const renderScene = (safeAreaInsets: EdgeInsets, keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
     // If function children, the caller handles the insets and overscroll
diff --git a/src/components/navigation/HeaderBackground.tsx b/src/components/navigation/HeaderBackground.tsx
index bf9e175cf87..45a210cdd21 100644
--- a/src/components/navigation/HeaderBackground.tsx
+++ b/src/components/navigation/HeaderBackground.tsx
@@ -1,17 +1,22 @@
 import * as React from 'react'
-import { StyleSheet, View } from 'react-native'
+import { StyleSheet } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
+import Animated, { interpolate, SharedValue, useAnimatedStyle } from 'react-native-reanimated'
 import { BlurView } from 'rn-id-blurview'
 
+import { useSceneScrollContext } from '../../state/SceneScrollState'
 import { styled } from '../hoc/styled'
 import { useTheme } from '../services/ThemeContext'
 import { DividerLine } from '../themed/DividerLine'
+import { MAX_TAB_BAR_HEIGHT } from '../themed/MenuTabs'
 
-export const HeaderBackground = () => {
+export const HeaderBackground = (props: any) => {
   const theme = useTheme()
 
+  const { scrollY } = useSceneScrollContext()
+
   return (
-    <HeaderBackgroundContainerView>
+    <HeaderBackgroundContainerView scrollY={scrollY}>
       <BlurView blurType={theme.isDark ? 'dark' : 'light'} style={StyleSheet.absoluteFill} overlayColor="#00000000" />
       <HeaderLinearGradient colors={theme.headerBackground} start={theme.headerBackgroundStart} end={theme.headerBackgroundEnd} />
       <DividerLine colors={theme.headerOutlineColors} />
@@ -19,11 +24,17 @@ export const HeaderBackground = () => {
   )
 }
 
-const HeaderBackgroundContainerView = styled(View)({
-  ...StyleSheet.absoluteFillObject,
-  alignItems: 'stretch',
-  justifyContent: 'flex-end'
-})
+const HeaderBackgroundContainerView = styled(Animated.View)<{ scrollY: SharedValue<number> }>(() => ({ scrollY }) => [
+  {
+    ...StyleSheet.absoluteFillObject,
+    alignItems: 'stretch',
+    justifyContent: 'flex-end',
+    opacity: 0
+  },
+  useAnimatedStyle(() => ({
+    opacity: interpolate(scrollY.value, [0, MAX_TAB_BAR_HEIGHT], [0, 1])
+  }))
+])
 
 const HeaderLinearGradient = styled(LinearGradient)({
   flex: 1
diff --git a/src/state/SceneScrollState.tsx b/src/state/SceneScrollState.tsx
index fc2fce27004..a49305944f8 100644
--- a/src/state/SceneScrollState.tsx
+++ b/src/state/SceneScrollState.tsx
@@ -1,70 +1,156 @@
-import { useMemo } from 'react'
+import { useIsFocused } from '@react-navigation/native'
+import { useCallback, useEffect, useMemo, useState } from 'react'
 import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
-import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
+import { SharedValue, useAnimatedScrollHandler, useDerivedValue, useSharedValue } from 'react-native-reanimated'
 
 import { createStateProvider } from './createStateProvider'
 
-export const [SceneScrollProvider, useSceneScrollContext] = createStateProvider(() => {
+interface InternalScrollState {
+  dragStartX: SharedValue<number>
+  dragStartY: SharedValue<number>
+  scrollX: SharedValue<number>
+  scrollY: SharedValue<number>
+  scrollBeginEvent: SharedValue<NativeScrollEvent | null>
+  scrollEndEvent: SharedValue<NativeScrollEvent | null>
+  scrollMomentumBeginEvent: SharedValue<NativeScrollEvent | null>
+  scrollMomentumEndEvent: SharedValue<NativeScrollEvent | null>
+}
+
+export interface ScrollContextValue {
+  scrollX: SharedValue<number>
+  scrollY: SharedValue<number>
+  scrollXDelta: SharedValue<number>
+  scrollYDelta: SharedValue<number>
+  scrollBeginEvent: SharedValue<NativeScrollEvent | null>
+  scrollEndEvent: SharedValue<NativeScrollEvent | null>
+  scrollMomentumBeginEvent: SharedValue<NativeScrollEvent | null>
+  scrollMomentumEndEvent: SharedValue<NativeScrollEvent | null>
+  updateScrollState: (state: InternalScrollState) => void
+}
+
+export const [SceneScrollProvider, useSceneScrollContext] = createStateProvider((): ScrollContextValue => {
+  const dragStartX = useSharedValue(0)
+  const dragStartY = useSharedValue(0)
   const scrollX = useSharedValue(0)
-  const scrollXDelta = useSharedValue(0)
   const scrollY = useSharedValue(0)
-  const scrollYDelta = useSharedValue(0)
   const scrollBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
   const scrollEndEvent = useSharedValue<NativeScrollEvent | null>(null)
   const scrollMomentumBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
   const scrollMomentumEndEvent = useSharedValue<NativeScrollEvent | null>(null)
 
-  return useMemo(
-    () => ({
-      scrollX,
+  const scrollXDelta = useDerivedValue(() => scrollX.value - dragStartX.value)
+  const scrollYDelta = useDerivedValue(() => scrollY.value - dragStartY.value)
+
+  const updateScrollState = useCallback((state: InternalScrollState) => {
+    setScrollState(state)
+  }, [])
+
+  const [scrollState, setScrollState] = useState<InternalScrollState>({
+    dragStartX,
+    dragStartY,
+    scrollX,
+    scrollY,
+    scrollBeginEvent,
+    scrollEndEvent,
+    scrollMomentumBeginEvent,
+    scrollMomentumEndEvent
+  })
+
+  return useMemo(() => {
+    return {
+      scrollX: scrollState.scrollX,
+      scrollY: scrollState.scrollY,
+      scrollBeginEvent: scrollState.scrollBeginEvent,
+      scrollEndEvent: scrollState.scrollEndEvent,
+      scrollMomentumBeginEvent: scrollState.scrollMomentumBeginEvent,
+      scrollMomentumEndEvent: scrollState.scrollMomentumEndEvent,
       scrollXDelta,
-      scrollY,
       scrollYDelta,
-      scrollBeginEvent,
-      scrollEndEvent,
-      scrollMomentumBeginEvent,
-      scrollMomentumEndEvent
-    }),
-    [scrollBeginEvent, scrollEndEvent, scrollMomentumBeginEvent, scrollMomentumEndEvent, scrollX, scrollXDelta, scrollY, scrollYDelta]
-  )
+      updateScrollState
+    }
+  }, [scrollState, scrollXDelta, scrollYDelta, updateScrollState])
 })
 
 export type SceneScrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>) => void
 
-export const useSceneScrollHandler = (): SceneScrollHandler => {
-  const sceneScrollContext = useSceneScrollContext()
+/**
+ * Return a Reanimated scroll handler (special worklet handler ref) to be attached
+ * to a animated scrollable component (Animate.ScrollView, Animate.FlatList, etc).
+ *
+ * The hook works by creating local component state of reanimated shared-values which
+ * are updated based on the scroll component's scroll position. This local state is
+ * passed to the global scroll state update function which stomps the global shared
+ * values with the local ones as the context provider's value. This will only happen
+ * if the scene is focused (react-navigation's useIsFocused). In addition to scene
+ * focus requirement, the caller of this hook has the option to control enabling
+ * the hook by the optional `isEnabled` boolean parameter.
+ */
+export const useSceneScrollHandler = (isEnabled: boolean = true): SceneScrollHandler => {
+  const { updateScrollState } = useSceneScrollContext()
+
+  // Local scroll state
   const dragStartX = useSharedValue(0)
   const dragStartY = useSharedValue(0)
+  const scrollX = useSharedValue(0)
+  const scrollY = useSharedValue(0)
+  const scrollBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
+  const scrollEndEvent = useSharedValue<NativeScrollEvent | null>(null)
+  const scrollMomentumBeginEvent = useSharedValue<NativeScrollEvent | null>(null)
+  const scrollMomentumEndEvent = useSharedValue<NativeScrollEvent | null>(null)
+
+  const isFocused = useIsFocused()
 
-  const handler = useAnimatedScrollHandler(
-    {
-      onScroll: (nativeEvent: NativeScrollEvent) => {
-        'worklet'
-        sceneScrollContext.scrollX.value = nativeEvent.contentOffset.y
-        sceneScrollContext.scrollXDelta.value = nativeEvent.contentOffset.x - dragStartX.value
-        sceneScrollContext.scrollY.value = nativeEvent.contentOffset.y
-        sceneScrollContext.scrollYDelta.value = nativeEvent.contentOffset.y - dragStartY.value
-      },
-      onBeginDrag: (nativeEvent: NativeScrollEvent) => {
-        'worklet'
-        dragStartX.value = nativeEvent.contentOffset.x
-        dragStartY.value = nativeEvent.contentOffset.y
-
-        sceneScrollContext.scrollBeginEvent.value = nativeEvent
-      },
-      onEndDrag: nativeEvent => {
-        'worklet'
-        sceneScrollContext.scrollEndEvent.value = nativeEvent
-      },
-      onMomentumBegin: nativeEvent => {
-        sceneScrollContext.scrollMomentumBeginEvent.value = nativeEvent
-      },
-      onMomentumEnd: nativeEvent => {
-        sceneScrollContext.scrollMomentumEndEvent.value = nativeEvent
-      }
+  useEffect(() => {
+    if (isFocused && isEnabled) {
+      updateScrollState({
+        dragStartX,
+        dragStartY,
+        scrollX,
+        scrollY,
+        scrollBeginEvent,
+        scrollEndEvent,
+        scrollMomentumBeginEvent,
+        scrollMomentumEndEvent
+      })
+    }
+  }, [
+    dragStartX,
+    dragStartY,
+    isEnabled,
+    isFocused,
+    scrollBeginEvent,
+    scrollEndEvent,
+    scrollMomentumBeginEvent,
+    scrollMomentumEndEvent,
+    scrollX,
+    scrollY,
+    updateScrollState
+  ])
+
+  const handler = useAnimatedScrollHandler({
+    onScroll: (nativeEvent: NativeScrollEvent) => {
+      'worklet'
+      scrollX.value = nativeEvent.contentOffset.x
+      scrollY.value = nativeEvent.contentOffset.y
+    },
+    onBeginDrag: (nativeEvent: NativeScrollEvent) => {
+      'worklet'
+      dragStartX.value = nativeEvent.contentOffset.x
+      dragStartY.value = nativeEvent.contentOffset.y
+
+      scrollBeginEvent.value = nativeEvent
+    },
+    onEndDrag: nativeEvent => {
+      'worklet'
+      scrollEndEvent.value = nativeEvent
+    },
+    onMomentumBegin: nativeEvent => {
+      scrollMomentumBeginEvent.value = nativeEvent
     },
-    []
-  )
+    onMomentumEnd: nativeEvent => {
+      scrollMomentumEndEvent.value = nativeEvent
+    }
+  })
 
   return handler
 }