@rific/scroll-view 0.2.0 → 0.4.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 +2 -0
- package/dist/index.d.mts +23 -17
- package/dist/index.d.ts +23 -17
- package/dist/index.js +720 -1960
- package/dist/index.mjs +697 -1939
- package/package.json +10 -10
- package/src/CustomList.tsx +32 -8
- package/src/FlatList.tsx +102 -20
- package/src/PullSearch.tsx +4 -3
- package/src/ScrollView.tsx +28 -8
- package/src/ScrollViewContext.ts +17 -4
- package/src/ScrollViewHeader.tsx +34 -31
- package/src/ScrollViewProvider.tsx +22 -6
- package/src/SectionList.tsx +31 -10
- package/src/internal/useScrollHandler.ts +19 -21
- package/src/internal/useScrollHandlerJS.ts +141 -0
- package/src/internal/useScrollList.ts +16 -11
- package/src/redux/scrollViewSlice.ts +19 -11
- package/src/useScrollInit.ts +200 -19
- package/src/__mocks__/auto-paper.ts +0 -9
- package/src/__mocks__/flash-list.ts +0 -20
- package/src/__mocks__/react-native-gesture-handler.ts +0 -13
- package/src/__mocks__/react-native-keyboard-controller.ts +0 -11
- package/src/__mocks__/react-native-paper.ts +0 -22
- package/src/__mocks__/react-native-reanimated.ts +0 -30
- package/src/__mocks__/react-native-safe-area-context.ts +0 -2
- package/src/__mocks__/react-native.ts +0 -30
- package/src/__tests__/CustomList.test.tsx +0 -61
- package/src/__tests__/FlatList.test.tsx +0 -52
- package/src/__tests__/PullSearch.test.tsx +0 -38
- package/src/__tests__/ScrollView.test.tsx +0 -23
- package/src/__tests__/ScrollViewHeader.test.tsx +0 -28
- package/src/__tests__/ScrollViewProvider.test.tsx +0 -65
- package/src/__tests__/SectionList.test.tsx +0 -88
- 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.4.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",
|
|
@@ -39,15 +39,15 @@
|
|
|
39
39
|
"types": "dist/index.d.ts",
|
|
40
40
|
"files": [
|
|
41
41
|
"dist",
|
|
42
|
-
"src"
|
|
42
|
+
"src",
|
|
43
|
+
"!src/__tests__",
|
|
44
|
+
"!src/__mocks__"
|
|
43
45
|
],
|
|
44
46
|
"scripts": {
|
|
45
47
|
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
46
|
-
"dev": "tsup src/index.ts --format cjs,esm --dts --clean --watch",
|
|
47
48
|
"fix": "eslint --fix",
|
|
48
49
|
"lint": "eslint",
|
|
49
50
|
"prepublishOnly": "npm run build",
|
|
50
|
-
"push": "yalc publish --push",
|
|
51
51
|
"release": "git push --follow-tags",
|
|
52
52
|
"release:major": "npm version major && git push --follow-tags",
|
|
53
53
|
"release:minor": "npm version minor && git push --follow-tags",
|
|
@@ -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.6.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,14 +90,14 @@
|
|
|
90
90
|
"typescript-eslint": "^8.59.3"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@rific/auto-paper": "^0.
|
|
93
|
+
"@rific/auto-paper": "^0.6.0",
|
|
94
94
|
"react": ">=18.0.0",
|
|
95
|
-
"react-native": ">=0.
|
|
96
|
-
"react-native-gesture-handler": "
|
|
97
|
-
"react-native-keyboard-controller": "
|
|
95
|
+
"react-native": ">=0.76.0",
|
|
96
|
+
"react-native-gesture-handler": ">=2.0.0",
|
|
97
|
+
"react-native-keyboard-controller": ">=1.0.0",
|
|
98
98
|
"react-native-paper": ">=5.0.0",
|
|
99
99
|
"react-native-reanimated": ">=3.0.0",
|
|
100
|
-
"react-native-safe-area-context": "
|
|
100
|
+
"react-native-safe-area-context": ">=5.0.0"
|
|
101
101
|
},
|
|
102
102
|
"publishConfig": {
|
|
103
103
|
"access": "public"
|
package/src/CustomList.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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
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 }
|
|
@@ -34,6 +36,30 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
|
|
|
34
36
|
const { ListHeaderComponent: listHeaderComponent, contentContainerStyle: externalContentContainerStyle, ...restProps } = props as Record<string, unknown>
|
|
35
37
|
|
|
36
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])
|
|
37
63
|
|
|
38
64
|
const scrollViewInternal = useRef<ScrollToOffsetRef | null>(null)
|
|
39
65
|
|
|
@@ -55,7 +81,10 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
|
|
|
55
81
|
handleScrollBeginDrag,
|
|
56
82
|
handleScrollEndDrag,
|
|
57
83
|
hiddenHeader,
|
|
58
|
-
|
|
84
|
+
onRemountSyncRetry,
|
|
85
|
+
onRemountSynced,
|
|
86
|
+
pullSearchMinHeight,
|
|
87
|
+
remountSyncTarget
|
|
59
88
|
} = useScrollInit({
|
|
60
89
|
listHeaderComponent: listHeaderComponent as ComponentType<object> | ReactElement | null | undefined,
|
|
61
90
|
onMomentumScrollEnd: externalMomentumScrollEnd,
|
|
@@ -79,12 +108,7 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
|
|
|
79
108
|
|
|
80
109
|
const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
81
110
|
|
|
82
|
-
const
|
|
83
|
-
if (!pullSearchHeight) return
|
|
84
|
-
scrollViewInternal.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
|
|
85
|
-
}, [contentInset.top, pullSearchHeight])
|
|
86
|
-
|
|
87
|
-
const onScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
111
|
+
const onScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget })
|
|
88
112
|
|
|
89
113
|
const handleScrollToTop = useCallback(() => {
|
|
90
114
|
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
package/src/FlatList.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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
7
|
import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
|
|
9
|
-
import {
|
|
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'> & {
|
|
@@ -27,12 +27,65 @@ export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshin
|
|
|
27
27
|
ref?: RefObject<RNFlatList | null>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, 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
|
-
|
|
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)
|
|
32
38
|
const isHorizontal = horizontal === true
|
|
33
39
|
const showDots = isHorizontal && pagingEnabled === true && (data?.length ?? 0) > 1
|
|
34
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
|
+
|
|
35
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])
|
|
36
89
|
|
|
37
90
|
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
38
91
|
scrollView.current?.scrollToOffset({ offset, animated })
|
|
@@ -44,7 +97,8 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
|
|
|
44
97
|
handleScrollBeginDrag,
|
|
45
98
|
handleScrollEndDrag,
|
|
46
99
|
hiddenHeader,
|
|
47
|
-
pullSearchMinHeight
|
|
100
|
+
pullSearchMinHeight,
|
|
101
|
+
remountTarget
|
|
48
102
|
} = useScrollInit({
|
|
49
103
|
listHeaderComponent: externalListHeaderComponent,
|
|
50
104
|
onMomentumScrollEnd: externalMomentumScrollEnd,
|
|
@@ -68,12 +122,31 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
|
|
|
68
122
|
|
|
69
123
|
const contentContainerStyle = useMemo(() => (isHorizontal ? externalContentContainerStyle : [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle]), [isHorizontal, contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
70
124
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
)
|
|
75
142
|
|
|
76
|
-
const
|
|
143
|
+
const onMomentumScrollEnd = useCallback(
|
|
144
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
145
|
+
scrollHandler.onMomentumScrollEnd(e)
|
|
146
|
+
handleMomentumScrollEnd(e)
|
|
147
|
+
},
|
|
148
|
+
[scrollHandler, handleMomentumScrollEnd]
|
|
149
|
+
)
|
|
77
150
|
|
|
78
151
|
const handleScrollToTop = useCallback(() => {
|
|
79
152
|
if (isHorizontal) {
|
|
@@ -98,23 +171,27 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
|
|
|
98
171
|
|
|
99
172
|
const content = (
|
|
100
173
|
<View style={containerStyle}>
|
|
101
|
-
<
|
|
174
|
+
<RNFlatList
|
|
102
175
|
{...(props as Omit<RNFlatListProps<T>, 'CellRendererComponent'>)}
|
|
103
176
|
contentContainerStyle={contentContainerStyle}
|
|
104
177
|
contentInset={contentInset}
|
|
178
|
+
contentInsetAdjustmentBehavior='never'
|
|
105
179
|
contentOffset={contentOffset}
|
|
106
|
-
data={data}
|
|
180
|
+
data={cols > 1 ? rowData : data}
|
|
107
181
|
horizontal={isHorizontal}
|
|
108
182
|
initialNumToRender={initialNumToRender}
|
|
183
|
+
keyExtractor={cols > 1 ? rowKeyExtractor : keyExtractor}
|
|
109
184
|
ListHeaderComponent={activeListHeader}
|
|
110
185
|
maxToRenderPerBatch={maxToRenderPerBatch}
|
|
111
186
|
onContentSizeChange={handleContentSizeChange}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
onMomentumScrollBegin={scrollHandler.onMomentumScrollBegin}
|
|
188
|
+
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
189
|
+
onScroll={scrollHandler.onScroll}
|
|
190
|
+
onScrollBeginDrag={onScrollBeginDrag}
|
|
191
|
+
onScrollEndDrag={onScrollEndDrag}
|
|
116
192
|
pagingEnabled={pagingEnabled}
|
|
117
|
-
|
|
193
|
+
renderItem={cols > 1 ? rowRenderItem : renderItem}
|
|
194
|
+
ref={(el: RNFlatList<T> | null) => {
|
|
118
195
|
if (externalRef) externalRef.current = el
|
|
119
196
|
scrollView.current = el
|
|
120
197
|
}}
|
|
@@ -137,6 +214,11 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
|
|
|
137
214
|
return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
|
|
138
215
|
}
|
|
139
216
|
|
|
140
|
-
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 } }
|
|
141
223
|
|
|
142
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,12 +1,13 @@
|
|
|
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
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 & {
|
|
@@ -30,6 +31,30 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, footerFixed: foot
|
|
|
30
31
|
const isHorizontal = horizontal === true
|
|
31
32
|
|
|
32
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])
|
|
33
58
|
|
|
34
59
|
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
35
60
|
scrollView.current?.scrollTo({ y: offset, animated })
|
|
@@ -43,12 +68,7 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, footerFixed: foot
|
|
|
43
68
|
scrollTo
|
|
44
69
|
})
|
|
45
70
|
|
|
46
|
-
const
|
|
47
|
-
if (!pullSearchHeight) return
|
|
48
|
-
scrollTo(-contentInset.top + pullSearchHeight, true)
|
|
49
|
-
}, [contentInset.top, pullSearchHeight, scrollTo])
|
|
50
|
-
|
|
51
|
-
const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
71
|
+
const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, listGeneration })
|
|
52
72
|
|
|
53
73
|
const handleScrollToTop = useCallback(() => {
|
|
54
74
|
if (isHorizontal) {
|
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>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useBlur } from '@rific/auto-paper'
|
|
2
|
-
import { type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
3
|
import { Dimensions } from 'react-native'
|
|
4
4
|
import { useSharedValue } from 'react-native-reanimated'
|
|
5
5
|
|
|
@@ -24,11 +24,13 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
|
|
|
24
24
|
const effectiveSnapBack = snapBack ?? settings.snapBack
|
|
25
25
|
const effectiveSnapBackHeader = snapBackHeader ?? settings.snapBackHeader
|
|
26
26
|
const effectiveSnapBackFooter = snapBackFooter ?? settings.snapBackFooter
|
|
27
|
-
const [headerHeight, setHeaderHeightState] = useState(
|
|
27
|
+
const [headerHeight, setHeaderHeightState] = useState<number | null>(null)
|
|
28
28
|
const [footerHeight, setFooterHeightState] = useState(0)
|
|
29
29
|
const [progress, setProgress] = useState<number | null>(null)
|
|
30
30
|
const [progressing, setProgressing] = useState(false)
|
|
31
31
|
const scrollPosition = useSharedValue(0)
|
|
32
|
+
const listGeneration = useSharedValue(0)
|
|
33
|
+
const jsListGeneration = useRef(0)
|
|
32
34
|
const headerHeightShared = useSharedValue(0)
|
|
33
35
|
const footerHeightShared = useSharedValue(0)
|
|
34
36
|
const headerOffset = useSharedValue(0)
|
|
@@ -41,12 +43,26 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
|
|
|
41
43
|
snapBackFooterShared.value = effectiveSnapBackFooter ?? effectiveSnapBack
|
|
42
44
|
}, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
|
|
43
45
|
const setHeaderHeight = useCallback(
|
|
44
|
-
(h: number) => {
|
|
46
|
+
(h: number | null) => {
|
|
45
47
|
setHeaderHeightState(h)
|
|
46
|
-
headerHeightShared.value = h
|
|
48
|
+
headerHeightShared.value = h ?? 0
|
|
47
49
|
},
|
|
48
50
|
[headerHeightShared]
|
|
49
51
|
)
|
|
52
|
+
const onListUnmount = useCallback(() => {
|
|
53
|
+
listGeneration.value += 1
|
|
54
|
+
}, [listGeneration])
|
|
55
|
+
const onJsListUnmount = useCallback(() => {
|
|
56
|
+
jsListGeneration.current += 1
|
|
57
|
+
}, [])
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!__DEV__ || headerHeight !== null) return
|
|
60
|
+
const timeout = setTimeout(() => {
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider — scroll content stays hidden (opacity: 0) until one does. Render a <ScrollViewHeader> (it can be empty / zero height) inside this provider.')
|
|
63
|
+
}, 3000)
|
|
64
|
+
return () => clearTimeout(timeout)
|
|
65
|
+
}, [headerHeight])
|
|
50
66
|
const setFooterHeight = useCallback(
|
|
51
67
|
(h: number) => {
|
|
52
68
|
setFooterHeightState(h)
|
|
@@ -54,9 +70,9 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
|
|
|
54
70
|
},
|
|
55
71
|
[footerHeightShared]
|
|
56
72
|
)
|
|
57
|
-
const scrollHeight = useMemo(() => Dimensions.get('window').height - headerHeight - footerHeight, [headerHeight, footerHeight])
|
|
73
|
+
const scrollHeight = useMemo(() => Dimensions.get('window').height - (headerHeight ?? 0) - footerHeight, [headerHeight, footerHeight])
|
|
58
74
|
const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
|
|
59
75
|
const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
|
|
60
|
-
const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
|
|
76
|
+
const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
|
|
61
77
|
return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
|
|
62
78
|
}
|