@rific/scroll-view 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming } from 'r
4
4
 
5
5
  import { ScrollViewContext } from '../ScrollViewContext'
6
6
  import { REMOUNT_SYNC_TOLERANCE } from '../useScrollInit'
7
+ import { chromeSettleTarget, chromeTravel } from './chrome'
7
8
  import { usesContentInset } from './insetMode'
8
9
 
9
10
  type UseScrollHandlerOptions = {
@@ -20,7 +21,7 @@ type UseScrollHandlerOptions = {
20
21
  }
21
22
 
22
23
  export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget }: UseScrollHandlerOptions) {
23
- const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
24
+ const { chromeHosted, chromeWritable, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared, stackHeightShared } = useContext(ScrollViewContext)
24
25
  const snapUpAccum = useSharedValue(0)
25
26
  const fromBottomBounce = useSharedValue(false)
26
27
 
@@ -58,7 +59,12 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
58
59
  fromBottomBounce.value = false
59
60
  }
60
61
  const snapHeader = snapBackHeaderShared.value && !headerFixed
61
- const snapFooter = snapBackFooterShared.value && !footerFixed
62
+ // A hosted footer is one half of a stack that has to slide its full hide distance to clear
63
+ // the screen; a list that can't scroll that far (short content) would strand the stack half
64
+ // hidden with no way to finish the job, so it never starts hiding in the first place.
65
+ const hideable = !chromeHosted || chromeTravel(maxScroll, headerHeightShared.value) >= stackHeightShared.value
66
+ if (chromeHosted && !hideable && chromeWritable.value && footerOffset.value !== 0) footerOffset.value = 0
67
+ const snapFooter = snapBackFooterShared.value && !footerFixed && chromeWritable.value && hideable
62
68
  if (snapHeader || snapFooter) {
63
69
  if (yn <= -headerHeightShared.value) {
64
70
  snapUpAccum.value = 0
@@ -68,7 +74,7 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
68
74
  snapUpAccum.value = 0
69
75
  if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) {
70
76
  if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
71
- if (snapFooter) footerOffset.value = Math.max(0, Math.min(footerHeightShared.value, footerOffset.value + delta))
77
+ if (snapFooter) footerOffset.value = Math.max(0, Math.min(stackHeightShared.value, footerOffset.value + delta))
72
78
  }
73
79
  } else if (delta < 0 && !fromBottomBounce.value) {
74
80
  if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.value -= delta
@@ -85,20 +91,35 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
85
91
  fromBottomBounce.value = false
86
92
  snapUpAccum.value = 0
87
93
  },
88
- onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
94
+ onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight }, velocity }) => {
89
95
  'worklet'
90
96
  if (isHorizontal) return
97
+ // The 10pt scroll-up latch (snapUpAccum >= 10) has already started animating the footer offset
98
+ // back to 0; read it before anything below resets it, because settling on the in-flight value
99
+ // instead could pick "hidden" and reverse a deliberate reveal.
100
+ const revealing = snapUpAccum.value >= 10
91
101
  if (y >= contentHeight - layoutHeight - 10) {
92
102
  fromBottomBounce.value = true
93
103
  snapUpAccum.value = 0
94
104
  }
105
+ // A drag that released with momentum still to come keeps writing the offset through it, and
106
+ // settles when that ends; only a release that will genuinely stop here settles now.
107
+ if (chromeHosted && !footerFixed && chromeWritable.value && (velocity === undefined || Math.abs(velocity.y) < 0.1)) {
108
+ footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
109
+ }
95
110
  },
96
111
  onMomentumEnd: () => {
97
112
  'worklet'
113
+ const revealing = snapUpAccum.value >= 10
98
114
  fromBottomBounce.value = false
99
115
  snapUpAccum.value = 0
116
+ // Hosted: scroll tracks 1:1, so it can stop with the stack half hidden. The bar is the app's
117
+ // primary navigation, so it comes to rest fully shown or fully hidden, never cut off.
118
+ if (chromeHosted && !isHorizontal && !footerFixed && chromeWritable.value) {
119
+ footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
120
+ }
100
121
  }
101
122
  },
102
- [capturedGeneration, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
123
+ [capturedGeneration, chromeHosted, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
103
124
  )
104
125
  }
@@ -4,6 +4,7 @@ import type { SharedValue } from 'react-native-reanimated'
4
4
  import { withTiming } from 'react-native-reanimated'
5
5
 
6
6
  import { ScrollViewContext } from '../ScrollViewContext'
7
+ import { chromeSettleTarget, chromeTravel } from './chrome'
7
8
  import { usesContentInset } from './insetMode'
8
9
 
9
10
  const REMOUNT_RETRY_TOLERANCE = 1
@@ -36,7 +37,7 @@ export type ScrollHandlerJS = {
36
37
  // their next UI-thread frame. This trades a small amount of latency for a plain scrollable that
37
38
  // reliably honors the initial `contentOffset` prop on (re)mount, which Animated.FlatList does not.
38
39
  export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget = null, scrollTo }: UseScrollHandlerJSOptions): ScrollHandlerJS {
39
- const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
40
+ const { chromeHosted, chromeWritable, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared, stackHeightShared } = useContext(ScrollViewContext)
40
41
  const snapUpAccum = useRef(0)
41
42
  const fromBottomBounce = useRef(false)
42
43
  // On a remount, the very first native position after mount isn't reliable — Fabric can skip
@@ -88,7 +89,11 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
88
89
  fromBottomBounce.current = false
89
90
  }
90
91
  const snapHeader = snapBackHeaderShared.value && !headerFixed
91
- const snapFooter = snapBackFooterShared.value && !footerFixed
92
+ // See useScrollHandler: a hosted stack that this list can't scroll far enough to fully hide
93
+ // never starts hiding.
94
+ const hideable = !chromeHosted || chromeTravel(maxScroll, headerHeightShared.value) >= stackHeightShared.value
95
+ if (chromeHosted && !hideable && chromeWritable.value && footerOffset.value !== 0) footerOffset.value = 0
96
+ const snapFooter = snapBackFooterShared.value && !footerFixed && chromeWritable.value && hideable
92
97
  if (snapHeader || snapFooter) {
93
98
  if (yn <= -headerHeightShared.value) {
94
99
  snapUpAccum.current = 0
@@ -98,7 +103,7 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
98
103
  snapUpAccum.current = 0
99
104
  if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) {
100
105
  if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
101
- if (snapFooter) footerOffset.value = Math.max(0, Math.min(footerHeightShared.value, footerOffset.value + delta))
106
+ if (snapFooter) footerOffset.value = Math.max(0, Math.min(stackHeightShared.value, footerOffset.value + delta))
102
107
  }
103
108
  } else if (delta < 0 && !fromBottomBounce.current) {
104
109
  if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.current -= delta
@@ -110,7 +115,7 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
110
115
  }
111
116
  }
112
117
  },
113
- [capturedGeneration, chipHidden, chipThreshold, footerFixed, footerHeightShared, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared]
118
+ [capturedGeneration, chipHidden, chipThreshold, chromeHosted, chromeWritable, footerFixed, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared, stackHeightShared]
114
119
  )
115
120
 
116
121
  const onScrollBeginDrag = useCallback(() => {
@@ -120,26 +125,45 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
120
125
 
121
126
  const onMomentumScrollBegin = useCallback(() => {}, [])
122
127
 
128
+ // Hosted: scroll tracks 1:1, so it can stop with the stack half hidden; it settles to fully shown
129
+ // or fully hidden instead, since the bar is the app's primary navigation. `revealing` is whether
130
+ // the 10pt scroll-up latch has already started animating the offset back to 0 — settling on the
131
+ // in-flight value instead could pick "hidden" and reverse a deliberate reveal.
132
+ const settle = useCallback(
133
+ (revealing: boolean) => {
134
+ if (!chromeHosted || footerFixed || !chromeWritable.value) return
135
+ footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
136
+ },
137
+ [chromeHosted, chromeWritable, footerFixed, footerOffset, stackHeightShared]
138
+ )
139
+
123
140
  const onScrollEndDrag = useCallback(
124
141
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
125
142
  if (isHorizontal) return
126
143
  const {
127
144
  contentOffset: { y },
128
145
  contentSize: { height: contentHeight },
129
- layoutMeasurement: { height: layoutHeight }
146
+ layoutMeasurement: { height: layoutHeight },
147
+ velocity
130
148
  } = event.nativeEvent
149
+ const revealing = snapUpAccum.current >= 10
131
150
  if (y >= contentHeight - layoutHeight - 10) {
132
151
  fromBottomBounce.current = true
133
152
  snapUpAccum.current = 0
134
153
  }
154
+ // A release with momentum still to come keeps writing the offset through it and settles when
155
+ // that ends; only one that will genuinely stop here settles now.
156
+ if (velocity === undefined || Math.abs(velocity.y) < 0.1) settle(revealing)
135
157
  },
136
- [isHorizontal]
158
+ [isHorizontal, settle]
137
159
  )
138
160
 
139
161
  const onMomentumScrollEnd = useCallback(() => {
162
+ const revealing = snapUpAccum.current >= 10
140
163
  fromBottomBounce.current = false
141
164
  snapUpAccum.current = 0
142
- }, [])
165
+ if (!isHorizontal) settle(revealing)
166
+ }, [isHorizontal, settle])
143
167
 
144
168
  return { onMomentumScrollBegin, onMomentumScrollEnd, onScroll, onScrollBeginDrag, onScrollEndDrag }
145
169
  }
@@ -25,10 +25,13 @@ export type UseScrollListOptions = {
25
25
  export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
26
26
  const insets = useSafeAreaInsets()
27
27
  const keyboardHeight = useKeyboardInset()
28
- const { footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerHeightShared, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
28
+ const { chromeHosted, chromeOverhang, footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerHeightShared, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
29
29
 
30
30
  const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
31
- const footerFixed = isHorizontal ? true : (footerFixedProp ?? contextFooterFixed)
31
+ // Hosted, the footer and the bar share ONE offset that the provider's registrar reveals when Footer
32
+ // Lock turns on — and the registrar only sees the provider-level flag. A per-list override that
33
+ // turned the handlers off on its own would leave the stack stuck wherever it was hidden.
34
+ const footerFixed = isHorizontal ? true : chromeHosted ? contextFooterFixed : (footerFixedProp ?? contextFooterFixed)
32
35
 
33
36
  useEffect(() => {
34
37
  pullSearchHeightShared.value = usesContentInset ? (pullSearchHeight ?? 0) : 0
@@ -59,11 +62,25 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
59
62
  // footerReserve + keyboardHeight in that case would leave a gap the size of the now-hidden footer
60
63
  // between the focused input and the keyboard, so max() reserves only whichever bottom obstruction
61
64
  // is actually taller right now.
65
+ //
66
+ // Hosted, tabBarHeight is the bar's footprint and it already contains the safe-area inset, so the
67
+ // base reserve is only what pokes above the bar (chromeOverhang) — not insets.bottom a second
68
+ // time. Hosted also reserves the screen footer's own height whether or not it is fixed: unlike an
69
+ // unhosted footer (whose position is a function of scroll, so scrolling to the end always hides
70
+ // it), a hosted stack settles wherever the last gesture left it, so a revealed footer at the end
71
+ // of a list would otherwise cover the last rows with no way to scroll them clear. This reserve
72
+ // stays constant even while the stack is hidden: contentInset can't animate, so the space stays
73
+ // reserved at the end of the list rather than the list growing as the bar slides away.
74
+ //
75
+ // A hosted, keyboard-floating footer pins itself at max(footprint - keyboardHeight, 0) instead of
76
+ // the full footprint (see ScrollViewFooter), so the bar reserve shrinks the same way.
62
77
  const insetGeometry = useMemo(() => {
63
- const footerReserve = footerFixed ? footerHeight || insets.bottom : insets.bottom
64
- const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + tabBarHeight
78
+ const baseReserve = chromeHosted ? chromeOverhang : insets.bottom
79
+ const footerReserve = chromeHosted ? Math.max(footerHeight ?? 0, baseReserve) : footerFixed ? footerHeight || baseReserve : baseReserve
80
+ const barReserve = chromeHosted && footerFixed && footerAboveKeyboard ? Math.max(tabBarHeight - keyboardHeight, 0) : tabBarHeight
81
+ const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + barReserve
65
82
  return { bottom, top: headerHeight ?? 0 }
66
- }, [footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
83
+ }, [chromeHosted, chromeOverhang, footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
67
84
  // Outside inset mode the same geometry is applied as content padding instead; contentInset is
68
85
  // zeroed (not omitted) so consumers' offset math — scroll-to-top targets, minHeight — stays
69
86
  // correct in the raw 0-based coordinate space those platforms actually scroll in.
@@ -0,0 +1,41 @@
1
+ import { useContext, useEffect } from 'react'
2
+ import type { StyleProp, ViewStyle } from 'react-native'
3
+ import { type AnimatedStyle, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
4
+
5
+ import { ScrollViewChromeContext } from './ScrollViewChromeContext'
6
+
7
+ const noop = () => {}
8
+
9
+ // Animated style for a persistent bar hosted by ScrollViewChromeProvider: translates it by the same
10
+ // shared offset the focused screen's footer uses, so the two hide and reveal together. Outside any
11
+ // host it is a permanent zero translate, so a bar can call this unconditionally.
12
+ //
13
+ // The return type is spelled out with reanimated's PUBLIC AnimatedStyle rather than left inferred:
14
+ // useAnimatedStyle's own inferred type (AnimatedStyleHandle) lives in a private reanimated deep path
15
+ // that the built .d.ts would otherwise import from, which only resolves for the exact reanimated
16
+ // file layout this was built against.
17
+ export const useScrollViewChromeStyle = (): StyleProp<AnimatedStyle<StyleProp<ViewStyle>>> => {
18
+ const chrome = useContext(ScrollViewChromeContext)
19
+ const fallback = useSharedValue(0)
20
+ const offset = chrome?.offset ?? fallback
21
+ return useAnimatedStyle(() => ({ transform: [{ translateY: offset.value }] }), [offset])
22
+ }
23
+
24
+ // Holds the bar fully revealed (and stops scroll from hiding it) while `active` is true. Pins are
25
+ // counted, so two independent pinners can't release each other. Inert outside a host.
26
+ export const useScrollViewChromePin = (active: boolean) => {
27
+ const chrome = useContext(ScrollViewChromeContext)
28
+ const addPin = chrome?.addPin
29
+ const removePin = chrome?.removePin
30
+ useEffect(() => {
31
+ if (!active || !addPin || !removePin) return undefined
32
+ addPin()
33
+ return removePin
34
+ }, [active, addPin, removePin])
35
+ }
36
+
37
+ // Returns a function that animates the bar back to fully revealed. Inert outside a host.
38
+ export const useScrollViewChromeReveal = () => {
39
+ const chrome = useContext(ScrollViewChromeContext)
40
+ return chrome?.reveal ?? noop
41
+ }