@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/scroll-view",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Blur styled scroll system for React Native. Floating headers/footers, keyboard awareness, and scroll-away chrome",
5
5
  "keywords": [
6
6
  "react-native",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@reduxjs/toolkit": "^2.12.0",
62
- "@rific/auto-paper": "^0.3.1",
62
+ "@rific/auto-paper": "^0.4.0",
63
63
  "@testing-library/dom": "^10.4.1",
64
64
  "@testing-library/react": "^16.3.2",
65
65
  "@types/jest": "^30.0.0",
@@ -90,7 +90,7 @@
90
90
  "typescript-eslint": "^8.59.3"
91
91
  },
92
92
  "peerDependencies": {
93
- "@rific/auto-paper": "^0.3.1",
93
+ "@rific/auto-paper": "^0.3.1 || ^0.4.0 || ^0.5.0",
94
94
  "react": ">=18.0.0",
95
95
  "react-native": ">=0.73.0",
96
96
  "react-native-gesture-handler": "*",
@@ -1,22 +1,27 @@
1
- import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useMemo, useRef } from 'react'
1
+ import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
2
  import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
+ import { runOnUI, useSharedValue } from 'react-native-reanimated'
4
5
 
5
6
  import { RefreshControl } from './internal/RefreshControl'
6
- import { ScrollViewChip } from './internal/ScrollViewChip'
7
+ import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
7
8
  import { useScrollHandler } from './internal/useScrollHandler'
8
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
9
11
  import { useScrollInit } from './useScrollInit'
10
12
 
11
13
  type ScrollToOffsetRef = { scrollToOffset: (params: { animated?: boolean; offset: number }) => void }
12
14
 
13
15
  export type CustomListProps<P extends object> = Omit<P, 'contentInset' | 'contentOffset' | 'onScroll' | 'refreshControl' | 'scrollEventThrottle'> & {
16
+ chipProps?: ChipProps
17
+ chipThreshold?: number
14
18
  component: ComponentType<P>
15
19
  renderFilters?: ReactNode
16
20
  footerFixed?: boolean
17
21
  gesture?: GestureType
18
22
  headerFixed?: boolean
19
23
  keyboardAware?: boolean
24
+ onChipPress?: () => void
20
25
  onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
21
26
  onRefresh?: () => Promise<void> | void
22
27
  onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
@@ -26,11 +31,35 @@ export type CustomListProps<P extends object> = Omit<P, 'contentInset' | 'conten
26
31
  style?: StyleProp<ViewStyle>
27
32
  }
28
33
 
29
- const CustomListInner = <P extends object>({ component: List, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollRef, style, ...props }: CustomListProps<P>) => {
34
+ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component: List, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollRef, style, ...props }: CustomListProps<P>) => {
30
35
  // Intercept ListHeaderComponent so useScrollInit can apply the same 2-phase measurement as FlatList.
31
36
  const { ListHeaderComponent: listHeaderComponent, contentContainerStyle: externalContentContainerStyle, ...restProps } = props as Record<string, unknown>
32
37
 
33
38
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
39
+ const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
40
+ // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
41
+ // corrects it before any scroll event could observe the placeholder.
42
+ const capturedGeneration = useSharedValue(-1)
43
+ // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
44
+ // removed in a commit run before any layout-effect setups for fibers added in that same commit,
45
+ // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
46
+ // instance's unmount and the incoming instance's mount land in the same commit — with passive
47
+ // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
48
+ // capturing a stale generation and silently disabling this instance's onScroll handling forever.
49
+ // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
50
+ // on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
51
+ // there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
52
+ // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
53
+ // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
54
+ // only be queued after this component finishes mounting.
55
+ useLayoutEffect(() => {
56
+ const generation = listGeneration.value
57
+ runOnUI((gen: number) => {
58
+ 'worklet'
59
+ capturedGeneration.value = gen
60
+ })(generation)
61
+ }, [capturedGeneration, listGeneration])
62
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
34
63
 
35
64
  const scrollViewInternal = useRef<ScrollToOffsetRef | null>(null)
36
65
 
@@ -52,7 +81,10 @@ const CustomListInner = <P extends object>({ component: List, renderFilters, foo
52
81
  handleScrollBeginDrag,
53
82
  handleScrollEndDrag,
54
83
  hiddenHeader,
55
- pullSearchMinHeight
84
+ onRemountSyncRetry,
85
+ onRemountSynced,
86
+ pullSearchMinHeight,
87
+ remountSyncTarget
56
88
  } = useScrollInit({
57
89
  listHeaderComponent: listHeaderComponent as ComponentType<object> | ReactElement | null | undefined,
58
90
  onMomentumScrollEnd: externalMomentumScrollEnd,
@@ -76,12 +108,7 @@ const CustomListInner = <P extends object>({ component: List, renderFilters, foo
76
108
 
77
109
  const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
78
110
 
79
- const onPullSearchZoneEnter = useCallback(() => {
80
- if (!pullSearchHeight) return
81
- scrollViewInternal.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
82
- }, [contentInset.top, pullSearchHeight])
83
-
84
- const onScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
111
+ const onScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget })
85
112
 
86
113
  const handleScrollToTop = useCallback(() => {
87
114
  const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
@@ -116,7 +143,7 @@ const CustomListInner = <P extends object>({ component: List, renderFilters, foo
116
143
  {hiddenHeader}
117
144
  </View>
118
145
  )}
119
- <ScrollViewChip onPress={handleScrollToTop} style={chipStyle} />
146
+ <ScrollViewChip chipProps={chipProps} onChipPress={onChipPress} onPress={handleScrollToTop} style={chipStyle} />
120
147
  </View>
121
148
  )
122
149
  return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
package/src/FlatList.tsx CHANGED
@@ -1,21 +1,24 @@
1
- import { memo, type ReactNode, type RefObject, useCallback, useMemo, useRef } from 'react'
2
- import { Dimensions, FlatList as RNFlatList, type FlatListProps as RNFlatListProps, type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native'
1
+ import { Fragment, memo, type ReactNode, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
+ import { Dimensions, FlatList as RNFlatList, type FlatListProps as RNFlatListProps, type ListRenderItem, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
- import Animated from 'react-native-reanimated'
5
4
 
6
5
  import { HorizontalDots } from './internal/HorizontalDots'
7
6
  import { RefreshControl } from './internal/RefreshControl'
8
- import { ScrollViewChip } from './internal/ScrollViewChip'
9
- import { useScrollHandler } from './internal/useScrollHandler'
7
+ import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
8
+ import { useScrollHandlerJS } from './internal/useScrollHandlerJS'
10
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
11
11
  import { useScrollInit } from './useScrollInit'
12
12
 
13
13
  export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshing'> & {
14
+ chipProps?: ChipProps
15
+ chipThreshold?: number
14
16
  renderFilters?: ReactNode
15
17
  footerFixed?: boolean
16
18
  gesture?: GestureType
17
19
  headerFixed?: boolean
18
20
  keyboardAware?: boolean
21
+ onChipPress?: () => void
19
22
  onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
20
23
  onRefresh?: () => Promise<void> | void
21
24
  onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
@@ -24,12 +27,65 @@ export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshin
24
27
  ref?: RefObject<RNFlatList | null>
25
28
  }
26
29
 
27
- const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
28
- const scrollView = useRef<RNFlatList>(null)
30
+ const FlatListInner = <T,>({ chipProps, chipThreshold, columnWrapperStyle, contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, keyExtractor, maxToRenderPerBatch = 50, numColumns, renderFilters, renderItem, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
31
+ // Plain (non-Animated) FlatList: react-native-reanimated's Animated.FlatList does not reliably
32
+ // honor the initial `contentOffset` prop on a key-forced remount — the header/list can briefly
33
+ // render at the wrong position. A plain FlatList honors it correctly, so scroll position is driven
34
+ // by a regular JS onScroll callback (useScrollHandlerJS) instead of a Reanimated worklet — which
35
+ // also avoids the "WorkletEventHandler" crash that a worklet handler triggers when attached to a
36
+ // non-Animated component.
37
+ const scrollView = useRef<RNFlatList<T>>(null)
29
38
  const isHorizontal = horizontal === true
30
39
  const showDots = isHorizontal && pagingEnabled === true && (data?.length ?? 0) > 1
31
40
 
41
+ // RN's own FlatList implements numColumns by chunking `data` into rows internally, but throws if
42
+ // numColumns ever changes without the caller changing `key` to force a full remount. On iOS,
43
+ // Fabric pools/recycles native ScrollView instances by component type across such a remount, and
44
+ // the recycled instance's onScroll can silently stop firing for the new React instance entirely —
45
+ // a native event-routing issue, not something a JS-side retry loop can detect or correct. Doing
46
+ // the row-chunking ourselves here and always passing numColumns={1} through to the underlying
47
+ // RNFlatList means numColumns never "changes" from RN's perspective — only `data`/`renderItem`
48
+ // change, which RNFlatList treats as an ordinary prop update with no remount at all, so callers
49
+ // can toggle numColumns freely (e.g. switching a list to a grid) without ever hitting this class
50
+ // of bug.
51
+ const cols = numColumns && numColumns > 1 ? numColumns : 1
52
+ const rowData = useMemo(() => {
53
+ if (cols <= 1 || !data) return data
54
+ const items = Array.from(data)
55
+ const rows: T[][] = []
56
+ for (let i = 0; i < items.length; i += cols) rows.push(items.slice(i, i + cols))
57
+ return rows as unknown as readonly T[]
58
+ }, [cols, data])
59
+ const rowKeyExtractor = useCallback(
60
+ (row: T | T[], rowIndex: number) => {
61
+ if (cols <= 1) return keyExtractor ? keyExtractor(row as T, rowIndex) : defaultKeyExtractor(row as T, rowIndex)
62
+ return (row as T[]).map((item, kk) => (keyExtractor ? keyExtractor(item, rowIndex * cols + kk) : defaultKeyExtractor(item, rowIndex * cols + kk))).join(':')
63
+ },
64
+ [cols, keyExtractor]
65
+ )
66
+ const rowRenderItem = useCallback<ListRenderItem<T>>(
67
+ (info) => {
68
+ if (cols <= 1) return renderItem ? renderItem(info) : null
69
+ const row = info.item as unknown as T[]
70
+ return (
71
+ <View style={[styles.row, columnWrapperStyle] as StyleProp<ViewStyle>}>
72
+ {row.map((item, kk) => (
73
+ <Fragment key={kk}>{renderItem ? renderItem({ index: info.index * cols + kk, item, separators: info.separators }) : null}</Fragment>
74
+ ))}
75
+ </View>
76
+ )
77
+ },
78
+ [cols, columnWrapperStyle, renderItem]
79
+ )
80
+
32
81
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
82
+ const { jsListGeneration, onJsListUnmount } = useContext(ScrollViewContext)
83
+ // Plain ref, not a SharedValue: see the comment on ScrollViewContextType.jsListGeneration for why.
84
+ const capturedGeneration = useRef(jsListGeneration.current)
85
+ useLayoutEffect(() => {
86
+ capturedGeneration.current = jsListGeneration.current
87
+ }, [jsListGeneration])
88
+ useLayoutEffect(() => () => onJsListUnmount(), [onJsListUnmount])
33
89
 
34
90
  const scrollTo = useCallback((offset: number, animated: boolean) => {
35
91
  scrollView.current?.scrollToOffset({ offset, animated })
@@ -41,7 +97,8 @@ const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyl
41
97
  handleScrollBeginDrag,
42
98
  handleScrollEndDrag,
43
99
  hiddenHeader,
44
- pullSearchMinHeight
100
+ pullSearchMinHeight,
101
+ remountTarget
45
102
  } = useScrollInit({
46
103
  listHeaderComponent: externalListHeaderComponent,
47
104
  onMomentumScrollEnd: externalMomentumScrollEnd,
@@ -65,12 +122,31 @@ const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyl
65
122
 
66
123
  const contentContainerStyle = useMemo(() => (isHorizontal ? externalContentContainerStyle : [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle]), [isHorizontal, contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
67
124
 
68
- const onPullSearchZoneEnter = useCallback(() => {
69
- if (!pullSearchHeight) return
70
- scrollView.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
71
- }, [contentInset.top, pullSearchHeight])
125
+ const scrollHandler = useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget, scrollTo })
126
+
127
+ const onScrollBeginDrag = useCallback(
128
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
129
+ scrollHandler.onScrollBeginDrag(e)
130
+ handleScrollBeginDrag(e)
131
+ },
132
+ [scrollHandler, handleScrollBeginDrag]
133
+ )
134
+
135
+ const onScrollEndDrag = useCallback(
136
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
137
+ scrollHandler.onScrollEndDrag(e)
138
+ handleScrollEndDrag(e)
139
+ },
140
+ [scrollHandler, handleScrollEndDrag]
141
+ )
72
142
 
73
- const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
143
+ const onMomentumScrollEnd = useCallback(
144
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
145
+ scrollHandler.onMomentumScrollEnd(e)
146
+ handleMomentumScrollEnd(e)
147
+ },
148
+ [scrollHandler, handleMomentumScrollEnd]
149
+ )
74
150
 
75
151
  const handleScrollToTop = useCallback(() => {
76
152
  if (isHorizontal) {
@@ -95,23 +171,27 @@ const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyl
95
171
 
96
172
  const content = (
97
173
  <View style={containerStyle}>
98
- <Animated.FlatList
174
+ <RNFlatList
99
175
  {...(props as Omit<RNFlatListProps<T>, 'CellRendererComponent'>)}
100
176
  contentContainerStyle={contentContainerStyle}
101
177
  contentInset={contentInset}
178
+ contentInsetAdjustmentBehavior='never'
102
179
  contentOffset={contentOffset}
103
- data={data}
180
+ data={cols > 1 ? rowData : data}
104
181
  horizontal={isHorizontal}
105
182
  initialNumToRender={initialNumToRender}
183
+ keyExtractor={cols > 1 ? rowKeyExtractor : keyExtractor}
106
184
  ListHeaderComponent={activeListHeader}
107
185
  maxToRenderPerBatch={maxToRenderPerBatch}
108
186
  onContentSizeChange={handleContentSizeChange}
109
- onMomentumScrollEnd={handleMomentumScrollEnd}
110
- onScroll={handleScroll}
111
- onScrollBeginDrag={handleScrollBeginDrag}
112
- onScrollEndDrag={handleScrollEndDrag}
187
+ onMomentumScrollBegin={scrollHandler.onMomentumScrollBegin}
188
+ onMomentumScrollEnd={onMomentumScrollEnd}
189
+ onScroll={scrollHandler.onScroll}
190
+ onScrollBeginDrag={onScrollBeginDrag}
191
+ onScrollEndDrag={onScrollEndDrag}
113
192
  pagingEnabled={pagingEnabled}
114
- ref={(el: RNFlatList | null) => {
193
+ renderItem={cols > 1 ? rowRenderItem : renderItem}
194
+ ref={(el: RNFlatList<T> | null) => {
115
195
  if (externalRef) externalRef.current = el
116
196
  scrollView.current = el
117
197
  }}
@@ -127,13 +207,18 @@ const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyl
127
207
  {hiddenHeader}
128
208
  </View>
129
209
  )}
130
- <ScrollViewChip isHorizontal={isHorizontal} onPress={handleScrollToTop} style={chipStyle} />
210
+ <ScrollViewChip chipProps={chipProps} isHorizontal={isHorizontal} onChipPress={onChipPress} onPress={handleScrollToTop} style={chipStyle} />
131
211
  {showDots && <HorizontalDots total={data?.length ?? 0} />}
132
212
  </View>
133
213
  )
134
214
  return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
135
215
  }
136
216
 
137
- const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 } }
217
+ const defaultKeyExtractor = <T,>(item: T, index: number): string => {
218
+ const key = (item as { id?: string; key?: string } | null)?.key ?? (item as { id?: string; key?: string } | null)?.id
219
+ return key != null ? String(key) : String(index)
220
+ }
221
+
222
+ const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 }, row: { flexDirection: 'row' as const } }
138
223
 
139
224
  export const FlatList = memo(FlatListInner) as typeof FlatListInner
@@ -42,7 +42,7 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
42
42
  useAnimatedReaction(
43
43
  () => {
44
44
  if (barHeight === 0) return false
45
- return scrollPosition.value >= -headerHeight + barHeight
45
+ return scrollPosition.value >= -(headerHeight ?? 0) + barHeight
46
46
  },
47
47
  (isHidden, wasHidden) => {
48
48
  if (isHidden && !wasHidden) runOnJS(blurInput)()
@@ -81,8 +81,9 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
81
81
  // hideY = -headerHeight + barHeight: bar scrolled behind header, opacity 0.
82
82
  const animatedStyle = useAnimatedStyle(() => {
83
83
  if (barHeight === 0) return { opacity: 0 }
84
- const showY = -headerHeight
85
- const hideY = -headerHeight + barHeight
84
+ const h = headerHeight ?? 0
85
+ const showY = -h
86
+ const hideY = -h + barHeight
86
87
  return { opacity: interpolate(scrollPosition.value, [showY, hideY], [1, 0], Extrapolation.CLAMP) }
87
88
  }, [barHeight, headerHeight])
88
89
 
@@ -1,19 +1,23 @@
1
- import { memo, type RefObject, useCallback, useMemo, useRef } from 'react'
1
+ import { memo, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
2
  import { type NativeScrollEvent, type NativeSyntheticEvent, ScrollView as RNScrollView, type ScrollViewProps as RNScrollViewProps, View } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
- import Animated from 'react-native-reanimated'
4
+ import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
5
5
 
6
6
  import { RefreshControl } from './internal/RefreshControl'
7
- import { ScrollViewChip } from './internal/ScrollViewChip'
7
+ import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
8
8
  import { useScrollHandler } from './internal/useScrollHandler'
9
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
10
11
  import { useScrollInit } from './useScrollInit'
11
12
 
12
13
  export type ScrollViewProps = RNScrollViewProps & {
14
+ chipProps?: ChipProps
15
+ chipThreshold?: number
13
16
  footerFixed?: boolean
14
17
  gesture?: GestureType
15
18
  headerFixed?: boolean
16
19
  keyboardAware?: boolean
20
+ onChipPress?: () => void
17
21
  onRefresh?: () => void
18
22
  onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
19
23
  onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
@@ -22,11 +26,35 @@ export type ScrollViewProps = RNScrollViewProps & {
22
26
  refreshing?: boolean
23
27
  }
24
28
 
25
- const ScrollViewInner = ({ children, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, style, ...props }: ScrollViewProps) => {
29
+ const ScrollViewInner = ({ chipProps, chipThreshold, children, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, style, ...props }: ScrollViewProps) => {
26
30
  const scrollView = useRef<RNScrollView>(null)
27
31
  const isHorizontal = horizontal === true
28
32
 
29
33
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
34
+ const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
35
+ // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
36
+ // corrects it before any scroll event could observe the placeholder.
37
+ const capturedGeneration = useSharedValue(-1)
38
+ // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
39
+ // removed in a commit run before any layout-effect setups for fibers added in that same commit,
40
+ // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
41
+ // instance's unmount and the incoming instance's mount land in the same commit — with passive
42
+ // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
43
+ // capturing a stale generation and silently disabling this instance's onScroll handling forever.
44
+ // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
45
+ // on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
46
+ // there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
47
+ // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
48
+ // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
49
+ // only be queued after this component finishes mounting.
50
+ useLayoutEffect(() => {
51
+ const generation = listGeneration.value
52
+ runOnUI((gen: number) => {
53
+ 'worklet'
54
+ capturedGeneration.value = gen
55
+ })(generation)
56
+ }, [capturedGeneration, listGeneration])
57
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
30
58
 
31
59
  const scrollTo = useCallback((offset: number, animated: boolean) => {
32
60
  scrollView.current?.scrollTo({ y: offset, animated })
@@ -40,12 +68,7 @@ const ScrollViewInner = ({ children, footerFixed: footerFixedProp, gesture, head
40
68
  scrollTo
41
69
  })
42
70
 
43
- const onPullSearchZoneEnter = useCallback(() => {
44
- if (!pullSearchHeight) return
45
- scrollTo(-contentInset.top + pullSearchHeight, true)
46
- }, [contentInset.top, pullSearchHeight, scrollTo])
47
-
48
- const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
71
+ const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, listGeneration })
49
72
 
50
73
  const handleScrollToTop = useCallback(() => {
51
74
  if (isHorizontal) {
@@ -80,7 +103,7 @@ const ScrollViewInner = ({ children, footerFixed: footerFixedProp, gesture, head
80
103
  >
81
104
  {children}
82
105
  </Animated.ScrollView>
83
- <ScrollViewChip isHorizontal={isHorizontal} onPress={handleScrollToTop} style={chipStyle} />
106
+ <ScrollViewChip chipProps={chipProps} isHorizontal={isHorizontal} onChipPress={onChipPress} onPress={handleScrollToTop} style={chipStyle} />
84
107
  </View>
85
108
  )
86
109
  return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
@@ -1,4 +1,4 @@
1
- import { createContext } from 'react'
1
+ import { createContext, type RefObject } from 'react'
2
2
  import { SharedValue } from 'react-native-reanimated'
3
3
 
4
4
  export type ScrollViewContextType = {
@@ -7,10 +7,19 @@ export type ScrollViewContextType = {
7
7
  footerHeightShared: SharedValue<number>
8
8
  footerFixed: boolean
9
9
  footerOffset: SharedValue<number>
10
- headerHeight: number
10
+ headerHeight: number | null
11
11
  headerHeightShared: SharedValue<number>
12
12
  headerFixed: boolean
13
13
  headerOffset: SharedValue<number>
14
+ // Plain (non-Reanimated) counter for JS-thread-only scroll handlers (FlatList's useScrollHandlerJS).
15
+ // A SharedValue is deliberately NOT used here: mutating `.value` from inside a React commit-phase
16
+ // callback (a layout effect) is not synchronously visible even to an immediate subsequent read in
17
+ // the same callback — Reanimated defers the actual flush — which silently broke the "did another
18
+ // list instance just unmount" check this counter exists for. A plain ref has no such batching.
19
+ jsListGeneration: RefObject<number>
20
+ listGeneration: SharedValue<number>
21
+ onJsListUnmount: () => void
22
+ onListUnmount: () => void
14
23
  progress: number | null
15
24
  pullSearchHeightShared: SharedValue<number>
16
25
  snapBackFooterShared: SharedValue<boolean>
@@ -19,7 +28,7 @@ export type ScrollViewContextType = {
19
28
  scrollHeight: number
20
29
  scrollPosition: SharedValue<number>
21
30
  setFooterHeight: (h: number) => void
22
- setHeaderHeight: (h: number) => void
31
+ setHeaderHeight: (h: number | null) => void
23
32
  setProgress: (p: number | null) => void
24
33
  setProgressing: (b: boolean) => void
25
34
  tabBarHeight: number
@@ -31,10 +40,14 @@ export const ScrollViewContext = createContext<ScrollViewContextType>({
31
40
  footerHeightShared: null as unknown as SharedValue<number>,
32
41
  footerFixed: false,
33
42
  footerOffset: null as unknown as SharedValue<number>,
34
- headerHeight: 0,
43
+ headerHeight: null,
35
44
  headerHeightShared: null as unknown as SharedValue<number>,
36
45
  headerFixed: false,
37
46
  headerOffset: null as unknown as SharedValue<number>,
47
+ jsListGeneration: { current: 0 },
48
+ listGeneration: null as unknown as SharedValue<number>,
49
+ onJsListUnmount: () => {},
50
+ onListUnmount: () => {},
38
51
  progress: null,
39
52
  progressing: false,
40
53
  pullSearchHeightShared: null as unknown as SharedValue<number>,
@@ -56,20 +56,22 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
56
56
  const translateStyle = useAnimatedStyle(() => {
57
57
  if (headerFixed) return { transform: [{ translateY: 0 }] }
58
58
  if (snapBackHeaderShared.value) return { transform: [{ translateY: headerOffset.value }] }
59
- const effective = scrollPosition.value + headerHeight - pullSearchHeightShared.value
59
+ const effective = scrollPosition.value + (headerHeight ?? 0) - pullSearchHeightShared.value
60
60
  if (effective <= 0) return { transform: [{ translateY: 0 }] }
61
61
  return { transform: [{ translateY: -effective }] }
62
62
  }, [headerFixed, headerHeight])
63
63
  const blurStyle = useAnimatedStyle(() => {
64
64
  if (!headerHeight) return { height: 0 }
65
65
  if (headerFixed) return { height: headerHeight }
66
- const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
67
- return { height: Math.max(headerHeight + translateY, top) }
66
+ const h = headerHeight ?? 0
67
+ const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
68
+ return { height: Math.max(h + translateY, top) }
68
69
  }, [headerFixed, headerHeight, top])
69
70
  const progressStyle = useAnimatedStyle(() => {
70
- if (headerFixed) return { top: headerHeight }
71
- const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
72
- return { top: Math.max(headerHeight - slide, top) }
71
+ const h = headerHeight ?? 0
72
+ if (headerFixed) return { top: h }
73
+ const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
74
+ return { top: Math.max(h - slide, top) }
73
75
  }, [headerFixed, headerHeight, top])
74
76
  const actionMargin = 4
75
77
  const contentMinHeight = actionSize + 2 * actionMargin
@@ -81,33 +83,34 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
81
83
  return (
82
84
  <>
83
85
  <Animated.View pointerEvents='none' style={[styles.blur, blurStyle]}>
84
- {headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
86
+ {headerHeight !== null && headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
85
87
  </Animated.View>
86
- <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === 0 ? styles.headerInit : styles.header, translateStyle]}>
88
+ <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === null ? styles.headerInit : styles.header, translateStyle]}>
87
89
  <View style={[{ paddingTop: top }, style]}>
88
- <View style={[styles.content, { minHeight: contentMinHeight }]}>
89
- <View style={styles.side} />
90
- <View style={styles.spacer} />
91
- <View style={styles.side}>{children}</View>
92
- {(centerContent || title || caption) && (
93
- <View style={[StyleSheet.absoluteFill, styles.titleContainer]} pointerEvents={centerContent ? 'box-none' : 'none'}>
94
- {centerContent ?? (
95
- <>
96
- {title && (
97
- <Text numberOfLines={1} style={[styles.title, { color: theme.colors.onSurface }]}>
98
- {title}
99
- </Text>
100
- )}
101
- {caption && (
102
- <Text numberOfLines={1} style={[styles.caption, { color: theme.colors.onSurfaceVariant }]}>
103
- {caption}
104
- </Text>
105
- )}
106
- </>
107
- )}
108
- </View>
109
- )}
110
- </View>
90
+ {children ?? (
91
+ <View style={[styles.content, { minHeight: contentMinHeight }]}>
92
+ <View style={styles.side} />
93
+ <View style={styles.spacer} />
94
+ {(centerContent || title || caption) && (
95
+ <View style={[StyleSheet.absoluteFill, styles.titleContainer]} pointerEvents={centerContent ? 'box-none' : 'none'}>
96
+ {centerContent ?? (
97
+ <>
98
+ {title && (
99
+ <Text numberOfLines={1} style={[styles.title, { color: theme.colors.onSurface }]}>
100
+ {title}
101
+ </Text>
102
+ )}
103
+ {caption && (
104
+ <Text numberOfLines={1} style={[styles.caption, { color: theme.colors.onSurfaceVariant }]}>
105
+ {caption}
106
+ </Text>
107
+ )}
108
+ </>
109
+ )}
110
+ </View>
111
+ )}
112
+ </View>
113
+ )}
111
114
  </View>
112
115
  {!blur && <View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />}
113
116
  </Animated.View>