about summary refs log tree commit diff
path: root/src/lib/hooks/useOnMainScroll.ts
blob: 2e7a799137be9606bd8ec040ca448fb50e4f6ece (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import {useState, useCallback, useMemo} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {
  useSharedValue,
  interpolate,
  runOnJS,
  ScrollHandlers,
} from 'react-native-reanimated'

function clamp(num: number, min: number, max: number) {
  'worklet'
  return Math.min(Math.max(num, min), max)
}

export type OnScrollCb = (
  event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollHandler = ScrollHandlers<any>
export type ResetCb = () => void

export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
  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)

  const onBeginDrag = useCallback(
    (e: NativeScrollEvent) => {
      'worklet'
      startDragOffset.value = e.contentOffset.y
      startMode.value = mode.value
    },
    [mode, startDragOffset, startMode],
  )

  const onEndDrag = useCallback(
    (e: NativeScrollEvent) => {
      'worklet'
      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)
      }
    },
    [startDragOffset, startMode, setMode, mode, headerHeight],
  )

  const onScroll = useCallback(
    (e: NativeScrollEvent) => {
      'worklet'
      // 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 (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 (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
        }
        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
      }
    },
    [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
  )

  const scrollHandler: ScrollHandlers<any> = useMemo(
    () => ({
      onBeginDrag,
      onEndDrag,
      onScroll,
    }),
    [onBeginDrag, onEndDrag, onScroll],
  )

  return [
    scrollHandler,
    isScrolledDown,
    useCallback(() => {
      setIsScrolledDown(false)
      setMode(false)
    }, [setMode]),
  ]
}