about summary refs log tree commit diff
path: root/src/view/com/util/MainScrollProvider.tsx
blob: 2c90e33ff1f1f837c01cbf7c9d2516b0e32922e6 (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
126
127
128
129
130
131
132
133
134
135
import React, {useCallback, useEffect} from 'react'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated'

const WEB_HIDE_SHELL_THRESHOLD = 200

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

export function MainScrollProvider({children}: {children: React.ReactNode}) {
  const {headerHeight} = useShellLayout()
  const mode = useMinimalShellMode()
  const setMode = useSetMinimalShellMode()
  const startDragOffset = useSharedValue<number | null>(null)
  const startMode = useSharedValue<number | null>(null)

  useEffect(() => {
    if (isWeb) {
      return listenToForcedWindowScroll(() => {
        startDragOffset.value = null
        startMode.value = null
      })
    }
  })

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

  const onEndDrag = useCallback(
    (e: NativeScrollEvent) => {
      'worklet'
      if (isNative) {
        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'
      if (isNative) {
        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
        }

        // 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
        }
      } else {
        // On the web, we don't try to follow the drag because we don't know when it ends.
        // Instead, show/hide immediately based on whether we're scrolling up or down.
        const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
        startDragOffset.value = e.contentOffset.y

        if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
          setMode(false)
        } else if (dy > 0) {
          setMode(true)
        }
      }
    },
    [headerHeight, mode, setMode, startDragOffset, startMode],
  )

  return (
    <ScrollProvider
      onBeginDrag={onBeginDrag}
      onEndDrag={onEndDrag}
      onScroll={onScroll}>
      {children}
    </ScrollProvider>
  )
}

const emitter = new EventEmitter()

if (isWeb) {
  const originalScroll = window.scroll
  window.scroll = function () {
    emitter.emit('forced-scroll')
    return originalScroll.apply(this, arguments as any)
  }

  const originalScrollTo = window.scrollTo
  window.scrollTo = function () {
    emitter.emit('forced-scroll')
    return originalScrollTo.apply(this, arguments as any)
  }
}

function listenToForcedWindowScroll(listener: () => void) {
  emitter.addListener('forced-scroll', listener)
  return () => {
    emitter.removeListener('forced-scroll', listener)
  }
}