@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/README.md +46 -7
- package/dist/index.d.mts +31 -10
- package/dist/index.d.ts +31 -10
- package/dist/index.js +663 -335
- package/dist/index.mjs +600 -272
- package/package.json +3 -3
- package/src/CustomList.tsx +38 -11
- package/src/FlatList.tsx +107 -22
- package/src/PullSearch.tsx +4 -3
- package/src/ScrollView.tsx +34 -11
- package/src/ScrollViewContext.ts +17 -4
- package/src/ScrollViewHeader.tsx +34 -31
- package/src/ScrollViewProvider.tsx +22 -6
- package/src/ScrollViewSettingsProvider.tsx +3 -3
- package/src/SectionList.tsx +37 -13
- package/src/__mocks__/auto-paper.ts +1 -0
- package/src/__mocks__/react-native-reanimated.ts +32 -1
- package/src/__tests__/FlatList.test.tsx +83 -0
- package/src/__tests__/ScrollViewProvider.test.tsx +2 -2
- package/src/__tests__/useScrollHandlerJS.test.ts +119 -0
- package/src/__tests__/useScrollInit.test.ts +251 -0
- package/src/index.ts +1 -0
- package/src/internal/ScrollViewChip.tsx +24 -8
- package/src/internal/useScrollHandler.ts +22 -23
- package/src/internal/useScrollHandlerJS.ts +141 -0
- package/src/internal/useScrollList.ts +16 -11
- package/src/useScrollInit.ts +200 -19
- package/src/out.txt +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rific/scroll-view",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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": "*",
|
package/src/CustomList.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
187
|
+
onMomentumScrollBegin={scrollHandler.onMomentumScrollBegin}
|
|
188
|
+
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
189
|
+
onScroll={scrollHandler.onScroll}
|
|
190
|
+
onScrollBeginDrag={onScrollBeginDrag}
|
|
191
|
+
onScrollEndDrag={onScrollEndDrag}
|
|
113
192
|
pagingEnabled={pagingEnabled}
|
|
114
|
-
|
|
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
|
|
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
|
package/src/PullSearch.tsx
CHANGED
|
@@ -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
|
|
85
|
-
const
|
|
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
|
|
package/src/ScrollView.tsx
CHANGED
|
@@ -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
|
|
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
|
package/src/ScrollViewContext.ts
CHANGED
|
@@ -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:
|
|
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>,
|
package/src/ScrollViewHeader.tsx
CHANGED
|
@@ -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
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 ===
|
|
88
|
+
<Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === null ? styles.headerInit : styles.header, translateStyle]}>
|
|
87
89
|
<View style={[{ paddingTop: top }, style]}>
|
|
88
|
-
|
|
89
|
-
<View style={styles.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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>
|