@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.
- package/README.md +175 -1
- package/dist/index.d.mts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +332 -153
- package/dist/index.mjs +308 -133
- package/package.json +1 -1
- package/src/ScrollViewChromeContext.ts +37 -0
- package/src/ScrollViewChromeProvider.tsx +65 -0
- package/src/ScrollViewContext.ts +16 -0
- package/src/ScrollViewFooter.tsx +51 -15
- package/src/ScrollViewHeader.tsx +24 -3
- package/src/ScrollViewProvider.tsx +96 -23
- package/src/index.ts +3 -0
- package/src/internal/ChromeRegistrar.tsx +37 -0
- package/src/internal/chrome.ts +19 -0
- package/src/internal/useScrollHandler.ts +26 -5
- package/src/internal/useScrollHandlerJS.ts +31 -7
- package/src/internal/useScrollList.ts +22 -5
- package/src/useScrollViewChrome.ts +41 -0
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
64
|
-
const
|
|
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
|
+
}
|