about summary refs log tree commit diff
path: root/src/screens/Profile/Header/GrowableBanner.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Profile/Header/GrowableBanner.tsx')
-rw-r--r--src/screens/Profile/Header/GrowableBanner.tsx212
1 files changed, 212 insertions, 0 deletions
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
new file mode 100644
index 000000000..bccc1e57e
--- /dev/null
+++ b/src/screens/Profile/Header/GrowableBanner.tsx
@@ -0,0 +1,212 @@
+import React, {useEffect, 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'
+import {useIsFetching} from '@tanstack/react-query'
+
+import {isIOS} from '#/platform/detection'
+import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs'
+import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed'
+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'
+
+const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
+
+export function GrowableBanner({
+  backButton,
+  children,
+}: {
+  backButton?: React.ReactNode
+  children: React.ReactNode
+}) {
+  const pagerContext = usePagerHeaderContext()
+
+  // pagerContext should only be present on iOS, but better safe than sorry
+  if (!pagerContext || !isIOS) {
+    return (
+      <View style={[a.w_full, a.h_full]}>
+        {backButton}
+        {children}
+      </View>
+    )
+  }
+
+  const {scrollY} = pagerContext
+
+  return (
+    <GrowableBannerInner scrollY={scrollY} backButton={backButton}>
+      {children}
+    </GrowableBannerInner>
+  )
+}
+
+function GrowableBannerInner({
+  scrollY,
+  backButton,
+  children,
+}: {
+  scrollY: SharedValue<number>
+  backButton?: React.ReactNode
+  children: React.ReactNode
+}) {
+  const isFetching = useIsProfileFetching()
+  const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    transform: [
+      {
+        scale: interpolate(scrollY.value, [-150, 0], [2, 1], {
+          extrapolateRight: Extrapolation.CLAMP,
+        }),
+      },
+    ],
+  }))
+
+  const animatedBlurViewProps = useAnimatedProps(() => {
+    return {
+      intensity: interpolate(
+        scrollY.value,
+        [-400, -100, -15],
+        [70, 60, 0],
+        Extrapolation.CLAMP,
+      ),
+    }
+  })
+
+  const animatedSpinnerStyle = useAnimatedStyle(() => {
+    return {
+      display: scrollY.value < 0 ? 'flex' : 'none',
+      opacity: interpolate(
+        scrollY.value,
+        [-60, -15],
+        [1, 0],
+        Extrapolation.CLAMP,
+      ),
+      transform: [
+        {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])},
+        {rotate: '90deg'},
+      ],
+    }
+  })
+
+  const animatedBackButtonStyle = useAnimatedStyle(() => ({
+    transform: [
+      {
+        translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
+          extrapolateRight: Extrapolation.CLAMP,
+        }),
+      },
+    ],
+  }))
+
+  return (
+    <>
+      <Animated.View
+        style={[
+          a.absolute,
+          {left: 0, right: 0, bottom: 0},
+          {height: 150},
+          {transformOrigin: 'bottom'},
+          animatedStyle,
+        ]}>
+        {children}
+        <AnimatedBlurView
+          style={[a.absolute, a.inset_0]}
+          tint="dark"
+          animatedProps={animatedBlurViewProps}
+        />
+      </Animated.View>
+      <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+        <Animated.View style={[animatedSpinnerStyle]}>
+          <ActivityIndicator
+            key={animateSpinner ? 'spin' : 'stop'}
+            size="large"
+            color="white"
+            animating={animateSpinner}
+            hidesWhenStopped={false}
+          />
+        </Animated.View>
+      </View>
+      <Animated.View style={[animatedBackButtonStyle]}>
+        {backButton}
+      </Animated.View>
+    </>
+  )
+}
+
+function useIsProfileFetching() {
+  // are any of the profile-related queries fetching?
+  return [
+    useIsFetching({queryKey: [FEED_RQKEY_ROOT]}),
+    useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}),
+    useIsFetching({queryKey: [LIST_RQKEY_ROOT]}),
+    useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}),
+  ].some(isFetching => isFetching)
+}
+
+function useShouldAnimateSpinner({
+  isFetching,
+  scrollY,
+}: {
+  isFetching: boolean
+  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 [isAnimating, setIsAnimating] = useState(isFetching)
+
+  if (isFetching && !isAnimating) {
+    setIsAnimating(true)
+  }
+
+  if (!isFetching && isAnimating && !stickyIsOverscrolled) {
+    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
+}