@rific/scroll-view 0.1.1 → 0.3.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.
@@ -1,8 +1,22 @@
1
1
  import { type ComponentType, createElement, type ReactElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
3
+ import { type SharedValue, useSharedValue } from 'react-native-reanimated'
3
4
 
4
5
  import { ScrollViewContext } from './ScrollViewContext'
5
6
 
7
+ const REMOUNT_SYNC_MAX_ATTEMPTS = 8
8
+ const REMOUNT_SYNC_TOLERANCE = 1
9
+ const REMOUNT_SYNC_FALLBACK_MS = 500
10
+ const REMOUNT_PRIME_FRAMES = 40
11
+ const SNAP_VERIFY_TOLERANCE = 1
12
+ const SNAP_VERIFY_MAX_ATTEMPTS = 5
13
+ // Long enough to comfortably outlast RN's default animated scrollTo duration (iOS's native
14
+ // setContentOffset:animated: for a typical pull-search distance settles well within this) — see
15
+ // the comment on scheduleSnapVerification for why a native onMomentumScrollEnd event can't be used
16
+ // as this signal instead.
17
+ const SNAP_VERIFY_INITIAL_DELAY_MS = 400
18
+ const SNAP_VERIFY_RETRY_DELAY_MS = 150
19
+
6
20
  export type UseScrollInitOptions = {
7
21
  listHeaderComponent?: ComponentType<object> | ReactElement | null
8
22
  onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
@@ -19,16 +33,129 @@ export type UseScrollInitResult = {
19
33
  handleScrollBeginDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
20
34
  handleScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
21
35
  hiddenHeader: ReactElement | null
36
+ onRemountSyncRetry: (currentY: number) => void
37
+ onRemountSynced: (y: number) => void
22
38
  pullSearchMinHeight: number
39
+ remountSyncTarget: SharedValue<number | null>
40
+ remountTarget: number | null
41
+ scrollReady: boolean
23
42
  }
24
43
 
25
44
  export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollTo }: UseScrollInitOptions): UseScrollInitResult {
26
45
  const { headerHeight, scrollPosition, setProgressing } = useContext(ScrollViewContext)
27
46
  const hasPullSearch = pullSearchHeight !== undefined
28
- const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch ? 'measuring' : 'ready')
47
+ // Start 'ready' immediately if headerHeight is already known (e.g. FlatList remount on mode
48
+ // switch). Only enter 'measuring' on the very first mount when headerHeight hasn't been set yet.
49
+ const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch && !headerHeight ? 'measuring' : 'ready')
29
50
  const [refreshing, setRefreshing] = useState(false)
51
+ // A plain-ref mirror of `refreshing`, read from inside the deferred timeout below — that
52
+ // callback can fire well after the render that scheduled it, so it needs the *current* value,
53
+ // not whatever `refreshing` closed over at schedule time.
54
+ const refreshingRef = useRef(refreshing)
55
+ useEffect(() => {
56
+ refreshingRef.current = refreshing
57
+ }, [refreshing])
30
58
  const dragStartY = useRef<number | null>(null)
31
- const deliberatelyOpened = useRef(false)
59
+ // Which pull-search resting position (showY or hideY) a drag-release most recently committed
60
+ // to, so a late fallback in handleMomentumScrollEnd knows not to fight it — see
61
+ // scheduleSnapVerification below for how this actually gets enforced.
62
+ const pendingSnapTarget = useRef<number | null>(null)
63
+ const snapVerifyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
64
+ const snapVerifyAttempts = useRef(0)
65
+
66
+ // Verifies a pull-search drag-release (handleScrollEndDrag) actually landed on `target`, and
67
+ // keeps correcting (non-animated, so it lands immediately) until it does. This is deliberately
68
+ // time-based rather than triggered by onMomentumScrollEnd: a slow/controlled release with near-
69
+ // zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending —
70
+ // long before the *animated* scrollTo call issued moments earlier has had any chance to actually
71
+ // move — so reacting to that event by immediately snapping (non-animated) to the target replaced
72
+ // the intended smooth spring-back animation with a jarring instant jump on every normal pull,
73
+ // not just the pathological case this was built for. Waiting out a fixed delay long enough for a
74
+ // normal animated scrollTo to have finished, then checking the *actual* position via
75
+ // scrollPosition.value (kept live by every onScroll), correctly leaves a genuinely-still-
76
+ // animating transition alone while still catching the real failure mode: a hard, fast fling from
77
+ // deep overscroll where iOS's own rubber-band deceleration outruns our animated scrollTo command
78
+ // and it never arrives on its own.
79
+ const scheduleSnapVerification = useCallback(
80
+ (target: number, delay: number) => {
81
+ if (snapVerifyTimeout.current !== null) clearTimeout(snapVerifyTimeout.current)
82
+ pendingSnapTarget.current = target
83
+ snapVerifyAttempts.current = 0
84
+ const check = () => {
85
+ snapVerifyTimeout.current = null
86
+ if (pendingSnapTarget.current !== target) return
87
+ if (Math.abs(scrollPosition.value - target) <= SNAP_VERIFY_TOLERANCE) {
88
+ pendingSnapTarget.current = null
89
+ return
90
+ }
91
+ snapVerifyAttempts.current += 1
92
+ if (snapVerifyAttempts.current > SNAP_VERIFY_MAX_ATTEMPTS || refreshingRef.current) {
93
+ pendingSnapTarget.current = null
94
+ return
95
+ }
96
+ scrollTo(target, false)
97
+ snapVerifyTimeout.current = setTimeout(check, SNAP_VERIFY_RETRY_DELAY_MS)
98
+ }
99
+ snapVerifyTimeout.current = setTimeout(check, delay)
100
+ },
101
+ [scrollPosition, scrollTo]
102
+ )
103
+
104
+ useEffect(
105
+ () => () => {
106
+ if (snapVerifyTimeout.current !== null) clearTimeout(snapVerifyTimeout.current)
107
+ },
108
+ []
109
+ )
110
+
111
+ // Remount sync: this instance mounted with headerHeight already known — a key-forced remount
112
+ // (e.g. numColumns toggling between a list and a grid), not a true first mount. The native
113
+ // scroll view's actual position right after such a remount isn't reliable (Fabric's ScrollView
114
+ // recycling and Reanimated's Animated.FlatList can both leave it at a stale or zeroed offset
115
+ // for the first render), so the list stays hidden until a confirmed onScroll event reports the
116
+ // intended resting position, re-issuing the imperative scrollTo in between as needed. This
117
+ // replaces trying to guess how many spurious events to skip.
118
+ const [remountTarget] = useState<number | null>(() => (headerHeight !== null ? -headerHeight + (pullSearchHeight ?? 0) : null))
119
+ const remountSyncTarget = useSharedValue<number | null>(remountTarget)
120
+ const [scrollReady, setScrollReady] = useState(remountTarget === null)
121
+ const remountAttempts = useRef(0)
122
+
123
+ const onRemountSynced = useCallback(
124
+ (y: number) => {
125
+ scrollPosition.value = y
126
+ setScrollReady(true)
127
+ },
128
+ [scrollPosition]
129
+ )
130
+
131
+ const onRemountSyncRetry = useCallback(
132
+ (currentY: number) => {
133
+ if (remountTarget === null) return
134
+ remountAttempts.current += 1
135
+ if (remountAttempts.current > REMOUNT_SYNC_MAX_ATTEMPTS) {
136
+ remountSyncTarget.value = null
137
+ onRemountSynced(currentY)
138
+ return
139
+ }
140
+ scrollTo(remountTarget, false)
141
+ },
142
+ [onRemountSynced, remountSyncTarget, remountTarget, scrollTo]
143
+ )
144
+
145
+ useEffect(() => {
146
+ if (remountTarget === null) return
147
+ // Prime the correction right after mount in case native doesn't emit an initial onScroll
148
+ // event on its own to kick off the retry loop above.
149
+ scrollTo(remountTarget, false)
150
+ const fallback = setTimeout(() => {
151
+ if (remountSyncTarget.value === null) return
152
+ remountSyncTarget.value = null
153
+ onRemountSynced(remountTarget)
154
+ }, REMOUNT_SYNC_FALLBACK_MS)
155
+ return () => clearTimeout(fallback)
156
+ // Intentionally runs once per mount only, using whatever remountTarget was captured at
157
+ // construction — not a dep that should retrigger this effect.
158
+ }, [])
32
159
 
33
160
  // Transition to 'ready' once both measurements are available
34
161
  useEffect(() => {
@@ -37,23 +164,62 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
37
164
  setPhase('ready')
38
165
  }, [phase, headerHeight, pullSearchHeight, scrollPosition])
39
166
 
40
- // Effect A: scroll to natural top the first time headerHeight is measured
167
+ // Effect A applies ONLY on an actual remount (remountTarget !== null — this instance mounted with
168
+ // headerHeight already known, e.g. a key-forced remount elsewhere in the tree). It syncs
169
+ // scrollPosition synchronously before first paint and issues a short bounded chain of imperative
170
+ // scrollTo calls, alternating between two offsets far enough apart (30pt — comfortably more than
171
+ // any plausible rounding/measurement noise) to guarantee at least one call can't be mistaken for a
172
+ // no-op. The declarative `contentOffset` prop alone isn't enough on a remount: when the target is
173
+ // numerically identical to what it was before (headerHeight/pullSearchHeight don't change across a
174
+ // mode toggle), Fabric's prop diffing can skip reapplying it to a recycled native view, which can
175
+ // be left at whatever position it actually drifted to. Worse, iOS's imperative scrollToOffset
176
+ // short-circuits with a CGPointEqualToPoint check against the native view's *actual* current
177
+ // contentOffset before it will fire the native scroll-finished callback that RN turns into an
178
+ // onScroll event — so a single differing call can still go unnoticed if it happens to race content
179
+ // that's still growing. Alternating across the *entire* correction window, not just the first
180
+ // couple of frames, forces a real scroll-finished callback so drift becomes observable and the
181
+ // onScroll-driven retry loop in useScrollHandlerJS can correct it.
182
+ //
183
+ // On a true first mount (remountTarget === null) this effect does nothing beyond a single
184
+ // insurance call against the destination view silently ignoring the very first scroll command
185
+ // because it isn't fully installed in the window hierarchy yet (e.g. mid native-stack push
186
+ // transition) — a fresh, non-recycled native view reliably honors the declarative contentOffset
187
+ // prop otherwise. Repeating or oscillating that call here fights Effect B below, whose entire job
188
+ // on a true first mount with pull-search is to push the position on from here to hideY: if this
189
+ // effect keeps re-asserting its own target for tens of frames, it can win the race and leave the
190
+ // pull-search bar stuck visible instead of pulled away, or eat scroll gestures attempted during
191
+ // that window.
41
192
  const headerInitialized = useRef(false)
42
193
  useLayoutEffect(() => {
43
194
  if (!headerHeight || headerInitialized.current) return
44
195
  headerInitialized.current = true
45
- scrollPosition.value = -headerHeight
46
- scrollTo(-headerHeight, false)
47
- }, [headerHeight, scrollPosition, scrollTo])
196
+ const target = remountTarget ?? -headerHeight
197
+ scrollPosition.value = target
198
+ scrollTo(target, false)
199
+ if (remountTarget === null) return
200
+ let framesLeft = REMOUNT_PRIME_FRAMES
201
+ let handle: number | null = null
202
+ const tick = () => {
203
+ const offset = framesLeft === 1 ? target : framesLeft % 2 === 0 ? target + 30 : target - 30
204
+ scrollTo(offset, false)
205
+ framesLeft -= 1
206
+ if (framesLeft > 0) handle = requestAnimationFrame(tick)
207
+ }
208
+ handle = requestAnimationFrame(tick)
209
+ return () => {
210
+ if (handle !== null) cancelAnimationFrame(handle)
211
+ }
212
+ }, [remountTarget, headerHeight, scrollPosition, scrollTo])
48
213
 
49
- // Effect B: scroll to hideY when pullSearch phase is ready (same Fabric commit as ListHeaderComponent addition)
214
+ // Effect B: true first-mount only, with pull search. Pushes from showY to hideY once ready.
215
+ // Remounts already land directly on hideY via the remount-sync mechanism above.
50
216
  const pullSearchInitialized = useRef(false)
51
217
  useLayoutEffect(() => {
52
- if (!hasPullSearch || phase !== 'ready' || pullSearchInitialized.current || !headerHeight || !pullSearchHeight) return
218
+ if (remountTarget !== null || !hasPullSearch || phase !== 'ready' || pullSearchInitialized.current || !headerHeight || !pullSearchHeight || !scrollReady) return
53
219
  pullSearchInitialized.current = true
54
220
  scrollPosition.value = -headerHeight + pullSearchHeight
55
221
  scrollTo(-headerHeight + pullSearchHeight, false)
56
- }, [hasPullSearch, phase, headerHeight, pullSearchHeight, scrollPosition, scrollTo])
222
+ }, [remountTarget, hasPullSearch, phase, headerHeight, pullSearchHeight, scrollPosition, scrollReady, scrollTo])
57
223
 
58
224
  useEffect(() => {
59
225
  if (pullSearchHeight) setProgressing(refreshing)
@@ -61,7 +227,11 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
61
227
 
62
228
  const handleScrollBeginDrag = useCallback(
63
229
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
64
- deliberatelyOpened.current = false
230
+ if (snapVerifyTimeout.current !== null) {
231
+ clearTimeout(snapVerifyTimeout.current)
232
+ snapVerifyTimeout.current = null
233
+ }
234
+ pendingSnapTarget.current = null
65
235
  if (pullSearchHeight) dragStartY.current = e.nativeEvent.contentOffset.y
66
236
  externalScrollBeginDrag?.(e)
67
237
  },
@@ -74,39 +244,50 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
74
244
  const y = e.nativeEvent.contentOffset.y
75
245
  const showY = -headerHeight
76
246
  const hideY = -headerHeight + pullSearchHeight
77
- const pulledDown = (dragStartY.current ?? hideY) <= hideY
247
+ const startY = dragStartY.current
78
248
  dragStartY.current = null
79
- if (pulledDown && y <= showY + pullSearchHeight * 0.5) {
80
- deliberatelyOpened.current = true
249
+ const pulledDown = (startY ?? hideY) <= hideY
250
+ const dragDistance = startY !== null ? startY - y : 0
251
+ if (pulledDown && dragDistance >= pullSearchHeight * 0.5) {
81
252
  if (y <= showY && !refreshing && onRefresh) {
82
253
  setRefreshing(true)
83
254
  Promise.resolve(onRefresh()).finally(() => setRefreshing(false))
84
255
  }
85
256
  scrollTo(showY, true)
257
+ scheduleSnapVerification(showY, SNAP_VERIFY_INITIAL_DELAY_MS)
86
258
  } else if (y < hideY) {
87
259
  scrollTo(hideY, true)
260
+ scheduleSnapVerification(hideY, SNAP_VERIFY_INITIAL_DELAY_MS)
88
261
  }
89
262
  }
90
263
  externalScrollEndDrag?.(e)
91
264
  },
92
- [externalScrollEndDrag, headerHeight, onRefresh, pullSearchHeight, refreshing, scrollTo]
265
+ [externalScrollEndDrag, headerHeight, onRefresh, pullSearchHeight, refreshing, scheduleSnapVerification, scrollTo]
93
266
  )
94
267
 
95
268
  const handleMomentumScrollEnd = useCallback(
96
269
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
97
- if (pullSearchHeight && headerHeight && !deliberatelyOpened.current && !refreshing) {
270
+ if (pullSearchHeight && headerHeight && !refreshing && pendingSnapTarget.current === null) {
271
+ // No drag committed to a target above (e.g. a fast fling whose whole reveal/overscroll
272
+ // motion happened during momentum, never during the drag itself, so handleScrollEndDrag
273
+ // never saw a y near the pull-search zone) — fall back to auto-closing if we've ended up
274
+ // revealed without ever meaning to.
98
275
  const y = e.nativeEvent.contentOffset.y
99
276
  const hideY = -headerHeight + pullSearchHeight
100
- if (y < hideY - 1) scrollTo(hideY, true)
277
+ if (y < hideY - 1) {
278
+ scrollTo(hideY, true)
279
+ scheduleSnapVerification(hideY, SNAP_VERIFY_INITIAL_DELAY_MS)
280
+ }
101
281
  }
102
- deliberatelyOpened.current = false
103
282
  externalMomentumScrollEnd?.(e)
104
283
  },
105
- [externalMomentumScrollEnd, headerHeight, pullSearchHeight, refreshing, scrollTo]
284
+ [externalMomentumScrollEnd, headerHeight, pullSearchHeight, refreshing, scheduleSnapVerification, scrollTo]
106
285
  )
107
286
 
108
287
  const activeListHeader = hasPullSearch && phase === 'measuring' ? null : listHeaderComponent
109
288
  const hiddenHeader = phase === 'measuring' && listHeaderComponent != null ? (typeof listHeaderComponent === 'function' ? createElement(listHeaderComponent as ComponentType<object>) : (listHeaderComponent as ReactElement)) : null
110
289
 
111
- return { activeListHeader, handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag, hiddenHeader, pullSearchMinHeight: pullSearchHeight ?? 0 }
290
+ return { activeListHeader, handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag, hiddenHeader, onRemountSyncRetry, onRemountSynced, pullSearchMinHeight: pullSearchHeight ?? 0, remountSyncTarget, remountTarget, scrollReady }
112
291
  }
292
+
293
+ export { REMOUNT_SYNC_TOLERANCE }
package/src/out.txt DELETED
@@ -1,61 +0,0 @@
1
- jay~/Developer/React-Native-Scroll-View% yalc publish && yalc push
2
- Running prepublishOnly script: npm run build
3
-
4
- > @rific/scroll-view@0.1.0 prepublishOnly
5
- > npm run build
6
-
7
-
8
- > @rific/scroll-view@0.1.0 build
9
- > tsup src/index.ts --format cjs,esm --dts --clean
10
-
11
- CLI Building entry: src/index.ts
12
- CLI Using tsconfig: tsconfig.json
13
- CLI tsup v8.5.1
14
- CLI Target: es2020
15
- CLI Cleaning output folder
16
- CJS Build start
17
- ESM Build start
18
- CJS dist/index.js 103.45 KB
19
- CJS ⚡️ Build success in 78ms
20
- ESM dist/index.mjs 95.24 KB
21
- ESM ⚡️ Build success in 78ms
22
- DTS Build start
23
- src/ScrollView.tsx(48,49): error TS2339: Property 'scrollTo' does not exist on type 'ScrollResponderMixin'.
24
-
25
- Error: error occurred in dts build
26
- at Worker.<anonymous> (/Users/jay/Developer/React-Native-Scroll-View/node_modules/tsup/dist/index.js:1545:26)
27
- at Worker.emit (node:events:509:20)
28
- at MessagePort.<anonymous> (node:internal/worker:333:53)
29
- at [nodejs.internal.kHybridDispatch] (node:internal/event_target:843:20)
30
- at MessagePort.<anonymous> (node:internal/per_context/messageport:23:28)
31
- DTS Build error
32
- yalc publish
33
-
34
- Publish package in yalc local repo
35
-
36
- Options:
37
- --version Show version number [boolean]
38
- --help Show help [boolean]
39
- --sig [boolean] [default: false]
40
- --dev-mod [boolean] [default: true]
41
- --workspace-resolve [default: true]
42
-
43
- Error: Command failed: npm run prepublishOnly
44
- at genericNodeError (node:internal/errors:998:15)
45
- at wrappedFn (node:internal/errors:543:14)
46
- at checkExecSyncError (node:child_process:925:11)
47
- at Object.execSync (node:child_process:997:15)
48
- at runPmScript (/opt/homebrew/lib/node_modules/yalc/src/publish.js:75:41)
49
- at Array.forEach (<anonymous>)
50
- at /opt/homebrew/lib/node_modules/yalc/src/publish.js:90:28
51
- at step (/opt/homebrew/lib/node_modules/yalc/src/publish.js:44:23)
52
- at Object.next (/opt/homebrew/lib/node_modules/yalc/src/publish.js:25:53)
53
- at /opt/homebrew/lib/node_modules/yalc/src/publish.js:19:71 {
54
- status: 1,
55
- signal: null,
56
- output: [ null, null, null ],
57
- pid: 71344,
58
- stdout: null,
59
- stderr: null
60
- }
61
- jay~/Developer/React-Native-Scroll-View%