@rific/scroll-view 0.8.3 → 0.9.1
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/LICENSE +21 -0
- package/README.md +199 -25
- package/dist/index.d.mts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +335 -156
- package/dist/index.mjs +311 -136
- package/package.json +4 -4
- package/src/CustomList.tsx +3 -3
- package/src/FlatList.tsx +4 -4
- package/src/PullSearch.tsx +1 -1
- package/src/ScrollView.tsx +4 -4
- package/src/ScrollViewChromeContext.ts +37 -0
- package/src/ScrollViewChromeProvider.tsx +65 -0
- package/src/ScrollViewContext.ts +17 -1
- package/src/ScrollViewFooter.tsx +56 -20
- package/src/ScrollViewHeader.tsx +31 -10
- package/src/ScrollViewProvider.tsx +100 -27
- package/src/SectionList.tsx +4 -4
- package/src/index.ts +3 -0
- package/src/internal/ChromeRegistrar.tsx +37 -0
- package/src/internal/HorizontalDots.tsx +1 -1
- package/src/internal/ScrollViewChip.tsx +3 -3
- package/src/internal/chrome.ts +19 -0
- package/src/internal/useScrollHandler.ts +26 -5
- package/src/internal/useScrollHandlerJS.ts +34 -10
- package/src/internal/useScrollList.ts +29 -12
- package/src/internal/useStickyHeaders.ts +3 -3
- package/src/redux/scrollViewSlice.ts +1 -1
- package/src/useKeyboardInset.ts +1 -1
- package/src/useScrollInit.ts +12 -12
- package/src/useScrollViewChrome.ts +41 -0
|
@@ -26,7 +26,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
26
26
|
contentIndexShared.value = activeIndex
|
|
27
27
|
}, [activeIndex, contentIndexShared])
|
|
28
28
|
|
|
29
|
-
// JS-side source of truth
|
|
29
|
+
// JS-side source of truth - sectionYPositions is a snapshot pushed to the worklet.
|
|
30
30
|
// Reading sectionYPositions.value from JS returns the UI-thread's stale copy, so
|
|
31
31
|
// rapid async measure callbacks would overwrite each other; refs avoid that.
|
|
32
32
|
const positionsCache = useRef<number[]>([])
|
|
@@ -65,7 +65,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
65
65
|
// Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
|
|
66
66
|
// function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
|
|
67
67
|
// snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
|
|
68
|
-
//
|
|
68
|
+
// - confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
|
|
69
69
|
// then never again on web, permanently freezing activeIndex/the active sticky section.
|
|
70
70
|
[enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
|
|
71
71
|
)
|
|
@@ -74,7 +74,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
74
74
|
// header as it slides above the sticky line during a push transition.
|
|
75
75
|
// Every SharedValue read below (headerHeightShared, scrollPosition, snapBackHeaderShared,
|
|
76
76
|
// headerOffset, pullSearchHeightShared, and for overlayStyle also contentIndexShared/
|
|
77
|
-
// sectionYPositions/sectionHeights) has to be listed in the array too
|
|
77
|
+
// sectionYPositions/sectionHeights) has to be listed in the array too - see
|
|
78
78
|
// ScrollViewHeader's translateStyle for why: on web, reanimated doesn't treat mutations to a
|
|
79
79
|
// SharedValue as a reactive trigger for useAnimatedStyle unless it's in this array.
|
|
80
80
|
const clipStyle = useAnimatedStyle(() => {
|
|
@@ -2,7 +2,7 @@ import { createSettingsSlice } from '@rific/core'
|
|
|
2
2
|
|
|
3
3
|
import { defaultScrollViewSettings, type ScrollViewSettings } from '../ScrollViewSettingsContext'
|
|
4
4
|
|
|
5
|
-
// scrollView has no per-field setters, just a full-replace initialize
|
|
5
|
+
// scrollView has no per-field setters, just a full-replace initialize - matches the previous
|
|
6
6
|
// hand-rolled slice exactly (action type 'scrollView/initialize', full-state-replace behavior).
|
|
7
7
|
const scrollViewSlice = createSettingsSlice<ScrollViewSettings>('scrollView', {
|
|
8
8
|
initialState: defaultScrollViewSettings,
|
package/src/useKeyboardInset.ts
CHANGED
|
@@ -23,6 +23,6 @@ const useKeyboardInsetNative = () => {
|
|
|
23
23
|
|
|
24
24
|
const useKeyboardInsetWeb = () => 0
|
|
25
25
|
|
|
26
|
-
// react-native-keyboard-controller has no web implementation
|
|
26
|
+
// react-native-keyboard-controller has no web implementation - its handler hook must never be
|
|
27
27
|
// called there. Platform is constant for the life of the bundle, so this select is hook-safe.
|
|
28
28
|
export const useKeyboardInset = Platform.OS === 'web' ? useKeyboardInsetWeb : useKeyboardInsetNative
|
package/src/useScrollInit.ts
CHANGED
|
@@ -12,7 +12,7 @@ const REMOUNT_PRIME_FRAMES = 40
|
|
|
12
12
|
const SNAP_VERIFY_TOLERANCE = 1
|
|
13
13
|
const SNAP_VERIFY_MAX_ATTEMPTS = 5
|
|
14
14
|
// Long enough to comfortably outlast RN's default animated scrollTo duration (iOS's native
|
|
15
|
-
// setContentOffset:animated: for a typical pull-search distance settles well within this)
|
|
15
|
+
// setContentOffset:animated: for a typical pull-search distance settles well within this) - see
|
|
16
16
|
// the comment on scheduleSnapVerification for why a native onMomentumScrollEnd event can't be used
|
|
17
17
|
// as this signal instead.
|
|
18
18
|
const SNAP_VERIFY_INITIAL_DELAY_MS = 400
|
|
@@ -51,7 +51,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
51
51
|
// switch). Only enter 'measuring' on the very first mount when headerHeight hasn't been set yet.
|
|
52
52
|
const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch && !headerHeight ? 'measuring' : 'ready')
|
|
53
53
|
const [refreshing, setRefreshing] = useState(false)
|
|
54
|
-
// A plain-ref mirror of `refreshing`, read from inside the deferred timeout below
|
|
54
|
+
// A plain-ref mirror of `refreshing`, read from inside the deferred timeout below - that
|
|
55
55
|
// callback can fire well after the render that scheduled it, so it needs the *current* value,
|
|
56
56
|
// not whatever `refreshing` closed over at schedule time.
|
|
57
57
|
const refreshingRef = useRef(refreshing)
|
|
@@ -60,7 +60,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
60
60
|
}, [refreshing])
|
|
61
61
|
const dragStartY = useRef<number | null>(null)
|
|
62
62
|
// Which pull-search resting position (showY or hideY) a drag-release most recently committed
|
|
63
|
-
// to, so a late fallback in handleMomentumScrollEnd knows not to fight it
|
|
63
|
+
// to, so a late fallback in handleMomentumScrollEnd knows not to fight it - see
|
|
64
64
|
// scheduleSnapVerification below for how this actually gets enforced.
|
|
65
65
|
const pendingSnapTarget = useRef<number | null>(null)
|
|
66
66
|
const snapVerifyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -69,9 +69,9 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
69
69
|
// Verifies a pull-search drag-release (handleScrollEndDrag) actually landed on `target`, and
|
|
70
70
|
// keeps correcting (non-animated, so it lands immediately) until it does. This is deliberately
|
|
71
71
|
// time-based rather than triggered by onMomentumScrollEnd: a slow/controlled release with near-
|
|
72
|
-
// zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending
|
|
72
|
+
// zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending -
|
|
73
73
|
// long before the *animated* scrollTo call issued moments earlier has had any chance to actually
|
|
74
|
-
// move
|
|
74
|
+
// move - so reacting to that event by immediately snapping (non-animated) to the target replaced
|
|
75
75
|
// the intended smooth spring-back animation with a jarring instant jump on every normal pull,
|
|
76
76
|
// not just the pathological case this was built for. Waiting out a fixed delay long enough for a
|
|
77
77
|
// normal animated scrollTo to have finished, then checking the *actual* position via
|
|
@@ -111,7 +111,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
111
111
|
[]
|
|
112
112
|
)
|
|
113
113
|
|
|
114
|
-
// Remount sync: this instance mounted with headerHeight already known
|
|
114
|
+
// Remount sync: this instance mounted with headerHeight already known - a key-forced remount
|
|
115
115
|
// (e.g. numColumns toggling between a list and a grid), not a true first mount. The native
|
|
116
116
|
// scroll view's actual position right after such a remount isn't reliable (Fabric's ScrollView
|
|
117
117
|
// recycling and Reanimated's Animated.FlatList can both leave it at a stale or zeroed offset
|
|
@@ -159,7 +159,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
159
159
|
}, REMOUNT_SYNC_FALLBACK_MS)
|
|
160
160
|
return () => clearTimeout(fallback)
|
|
161
161
|
// Intentionally runs once per mount only, using whatever remountTarget was captured at
|
|
162
|
-
// construction
|
|
162
|
+
// construction - not a dep that should retrigger this effect.
|
|
163
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
164
|
}, [])
|
|
165
165
|
|
|
@@ -170,10 +170,10 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
170
170
|
setPhase('ready')
|
|
171
171
|
}, [phase, headerHeight, pullSearchHeight, scrollPosition])
|
|
172
172
|
|
|
173
|
-
// Effect A applies ONLY on an actual remount (remountTarget !== null
|
|
173
|
+
// Effect A applies ONLY on an actual remount (remountTarget !== null - this instance mounted with
|
|
174
174
|
// headerHeight already known, e.g. a key-forced remount elsewhere in the tree). It syncs
|
|
175
175
|
// scrollPosition synchronously before first paint and issues a short bounded chain of imperative
|
|
176
|
-
// scrollTo calls, alternating between two offsets far enough apart (30pt
|
|
176
|
+
// scrollTo calls, alternating between two offsets far enough apart (30pt - comfortably more than
|
|
177
177
|
// any plausible rounding/measurement noise) to guarantee at least one call can't be mistaken for a
|
|
178
178
|
// no-op. The declarative `contentOffset` prop alone isn't enough on a remount: when the target is
|
|
179
179
|
// numerically identical to what it was before (headerHeight/pullSearchHeight don't change across a
|
|
@@ -181,7 +181,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
181
181
|
// be left at whatever position it actually drifted to. Worse, iOS's imperative scrollToOffset
|
|
182
182
|
// short-circuits with a CGPointEqualToPoint check against the native view's *actual* current
|
|
183
183
|
// contentOffset before it will fire the native scroll-finished callback that RN turns into an
|
|
184
|
-
// onScroll event
|
|
184
|
+
// onScroll event - so a single differing call can still go unnoticed if it happens to race content
|
|
185
185
|
// that's still growing. Alternating across the *entire* correction window, not just the first
|
|
186
186
|
// couple of frames, forces a real scroll-finished callback so drift becomes observable and the
|
|
187
187
|
// onScroll-driven retry loop in useScrollHandlerJS can correct it.
|
|
@@ -189,7 +189,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
189
189
|
// On a true first mount (remountTarget === null) this effect does nothing beyond a single
|
|
190
190
|
// insurance call against the destination view silently ignoring the very first scroll command
|
|
191
191
|
// because it isn't fully installed in the window hierarchy yet (e.g. mid native-stack push
|
|
192
|
-
// transition)
|
|
192
|
+
// transition) - a fresh, non-recycled native view reliably honors the declarative contentOffset
|
|
193
193
|
// prop otherwise. Repeating or oscillating that call here fights Effect B below, whose entire job
|
|
194
194
|
// on a true first mount with pull-search is to push the position on from here to hideY: if this
|
|
195
195
|
// effect keeps re-asserting its own target for tens of frames, it can win the race and leave the
|
|
@@ -277,7 +277,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
277
277
|
if (pullSearchHeight && headerHeight && !refreshing && pendingSnapTarget.current === null) {
|
|
278
278
|
// No drag committed to a target above (e.g. a fast fling whose whole reveal/overscroll
|
|
279
279
|
// motion happened during momentum, never during the drag itself, so handleScrollEndDrag
|
|
280
|
-
// never saw a y near the pull-search zone)
|
|
280
|
+
// never saw a y near the pull-search zone) - fall back to auto-closing if we've ended up
|
|
281
281
|
// revealed without ever meaning to.
|
|
282
282
|
const y = e.nativeEvent.contentOffset.y
|
|
283
283
|
const hideY = -headerHeight + pullSearchHeight
|
|
@@ -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
|
+
}
|