@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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { ScrollViewContext, type ScrollViewContextType } from '../ScrollViewContext'
|
|
5
|
+
import { useScrollInit } from '../useScrollInit'
|
|
6
|
+
|
|
7
|
+
const HEADER_HEIGHT = 80
|
|
8
|
+
const PULL_SEARCH_HEIGHT = 40
|
|
9
|
+
const REMOUNT_SYNC_MAX_ATTEMPTS = 8
|
|
10
|
+
const SNAP_VERIFY_MAX_ATTEMPTS = 5
|
|
11
|
+
const SNAP_VERIFY_INITIAL_DELAY_MS = 400
|
|
12
|
+
const SNAP_VERIFY_RETRY_DELAY_MS = 150
|
|
13
|
+
|
|
14
|
+
const buildContextValue = (headerHeight: number | null): ScrollViewContextType =>
|
|
15
|
+
({
|
|
16
|
+
blur: true,
|
|
17
|
+
footerHeight: 0,
|
|
18
|
+
footerHeightShared: { value: 0 },
|
|
19
|
+
footerFixed: false,
|
|
20
|
+
footerOffset: { value: 0 },
|
|
21
|
+
headerHeight,
|
|
22
|
+
headerHeightShared: { value: headerHeight ?? 0 },
|
|
23
|
+
headerFixed: false,
|
|
24
|
+
headerOffset: { value: 0 },
|
|
25
|
+
listGeneration: { value: 0 },
|
|
26
|
+
onListUnmount: jest.fn(),
|
|
27
|
+
progress: null,
|
|
28
|
+
progressing: false,
|
|
29
|
+
pullSearchHeightShared: { value: 0 },
|
|
30
|
+
scrollHeight: 0,
|
|
31
|
+
scrollPosition: { value: 0 },
|
|
32
|
+
setFooterHeight: jest.fn(),
|
|
33
|
+
setHeaderHeight: jest.fn(),
|
|
34
|
+
setProgress: jest.fn(),
|
|
35
|
+
setProgressing: jest.fn(),
|
|
36
|
+
snapBackFooterShared: { value: false },
|
|
37
|
+
snapBackHeaderShared: { value: false },
|
|
38
|
+
tabBarHeight: 60
|
|
39
|
+
}) as unknown as ScrollViewContextType
|
|
40
|
+
|
|
41
|
+
const renderInit = (headerHeight: number | null, pullSearchHeight?: number) => {
|
|
42
|
+
const contextValue = buildContextValue(headerHeight)
|
|
43
|
+
const scrollTo = jest.fn()
|
|
44
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(ScrollViewContext.Provider, { value: contextValue }, children)
|
|
45
|
+
const rendered = renderHook(() => useScrollInit({ pullSearchHeight, scrollTo }), { wrapper })
|
|
46
|
+
return { ...rendered, contextValue, scrollTo }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('useScrollInit remount sync', () => {
|
|
50
|
+
it('true first mount (headerHeight null) skips remount-sync and starts ready', () => {
|
|
51
|
+
const { result, unmount } = renderInit(null)
|
|
52
|
+
expect(result.current.scrollReady).toBe(true)
|
|
53
|
+
expect(result.current.remountSyncTarget.value).toBeNull()
|
|
54
|
+
unmount()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('remount (headerHeight already known at mount) hides the list and targets showY', () => {
|
|
58
|
+
const { result, scrollTo, unmount } = renderInit(HEADER_HEIGHT)
|
|
59
|
+
expect(result.current.scrollReady).toBe(false)
|
|
60
|
+
expect(result.current.remountSyncTarget.value).toBe(-HEADER_HEIGHT)
|
|
61
|
+
// Primes a corrective scrollTo immediately, rather than assuming native honored the initial contentOffset.
|
|
62
|
+
expect(scrollTo).toHaveBeenCalledWith(-HEADER_HEIGHT, false)
|
|
63
|
+
unmount()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('remount with pullSearchHeight targets hideY directly (skips the 2-phase first-mount dance)', () => {
|
|
67
|
+
const { result, unmount } = renderInit(HEADER_HEIGHT, 40)
|
|
68
|
+
expect(result.current.remountSyncTarget.value).toBe(-HEADER_HEIGHT + 40)
|
|
69
|
+
unmount()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('retries the corrective scrollTo when an onScroll event reports the wrong position, and stays hidden', () => {
|
|
73
|
+
const { result, scrollTo, unmount } = renderInit(HEADER_HEIGHT)
|
|
74
|
+
scrollTo.mockClear()
|
|
75
|
+
act(() => {
|
|
76
|
+
result.current.onRemountSyncRetry(0) // a spurious post-remount event reporting y=0
|
|
77
|
+
})
|
|
78
|
+
expect(scrollTo).toHaveBeenCalledWith(-HEADER_HEIGHT, false)
|
|
79
|
+
expect(result.current.scrollReady).toBe(false)
|
|
80
|
+
unmount()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reveals once a confirmed on-target event arrives', () => {
|
|
84
|
+
const { result, contextValue, unmount } = renderInit(HEADER_HEIGHT)
|
|
85
|
+
act(() => {
|
|
86
|
+
result.current.onRemountSynced(-HEADER_HEIGHT)
|
|
87
|
+
})
|
|
88
|
+
expect(result.current.scrollReady).toBe(true)
|
|
89
|
+
expect(contextValue.scrollPosition.value).toBe(-HEADER_HEIGHT)
|
|
90
|
+
unmount()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('gives up and reveals anyway after exceeding the max retry attempts, rather than hiding forever', () => {
|
|
94
|
+
const { result, unmount } = renderInit(HEADER_HEIGHT)
|
|
95
|
+
act(() => {
|
|
96
|
+
for (let i = 0; i < REMOUNT_SYNC_MAX_ATTEMPTS + 2; i += 1) result.current.onRemountSyncRetry(0)
|
|
97
|
+
})
|
|
98
|
+
expect(result.current.scrollReady).toBe(true)
|
|
99
|
+
expect(result.current.remountSyncTarget.value).toBeNull()
|
|
100
|
+
unmount()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const dragEvent = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as never
|
|
105
|
+
|
|
106
|
+
// A true first mount (not a remount): headerHeight starts null, then a rerender simulates the
|
|
107
|
+
// header's async measurement landing, mirroring the real production flow. Using a non-null
|
|
108
|
+
// headerHeight from the start (like the remount-sync tests above) would make useScrollInit treat
|
|
109
|
+
// this as a remount and pull in unrelated machinery — the priming scrollTo, the 40-frame RAF
|
|
110
|
+
// oscillation, and the REMOUNT_SYNC_FALLBACK_MS timer — none of which is relevant to the pull-search
|
|
111
|
+
// snap logic under test here.
|
|
112
|
+
const renderPullSearchInit = (pullSearchHeight: number) => {
|
|
113
|
+
const scrollTo = jest.fn()
|
|
114
|
+
let contextValue = buildContextValue(null)
|
|
115
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(ScrollViewContext.Provider, { value: contextValue }, children)
|
|
116
|
+
const rendered = renderHook(() => useScrollInit({ pullSearchHeight, scrollTo }), { wrapper })
|
|
117
|
+
act(() => {
|
|
118
|
+
contextValue = buildContextValue(HEADER_HEIGHT)
|
|
119
|
+
rendered.rerender()
|
|
120
|
+
})
|
|
121
|
+
scrollTo.mockClear() // drop Effect A's insurance call and Effect B's showY->hideY push
|
|
122
|
+
return { ...rendered, contextValue, scrollTo }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('useScrollInit pull-search snap', () => {
|
|
126
|
+
const showY = -HEADER_HEIGHT
|
|
127
|
+
const hideY = -HEADER_HEIGHT + PULL_SEARCH_HEIGHT
|
|
128
|
+
|
|
129
|
+
beforeEach(() => jest.useFakeTimers())
|
|
130
|
+
afterEach(() => jest.useRealTimers())
|
|
131
|
+
|
|
132
|
+
it('opens toward showY when dragged down past half the pull-search height', () => {
|
|
133
|
+
const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
134
|
+
act(() => {
|
|
135
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
136
|
+
result.current.handleScrollEndDrag(dragEvent(showY))
|
|
137
|
+
})
|
|
138
|
+
expect(scrollTo).toHaveBeenCalledWith(showY, true)
|
|
139
|
+
unmount()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('closes toward hideY when released without pulling past the half threshold', () => {
|
|
143
|
+
const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
144
|
+
act(() => {
|
|
145
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
146
|
+
result.current.handleScrollEndDrag(dragEvent(hideY - 10))
|
|
147
|
+
})
|
|
148
|
+
expect(scrollTo).toHaveBeenCalledWith(hideY, true)
|
|
149
|
+
unmount()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('does nothing when the release lands outside the pull-search zone entirely', () => {
|
|
153
|
+
const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
154
|
+
act(() => {
|
|
155
|
+
result.current.handleScrollBeginDrag(dragEvent(0))
|
|
156
|
+
result.current.handleScrollEndDrag(dragEvent(0))
|
|
157
|
+
})
|
|
158
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
159
|
+
unmount()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('keeps retrying (non-animated) until scrollPosition actually confirms the target, then stops', () => {
|
|
163
|
+
const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
164
|
+
contextValue.scrollPosition.value = hideY
|
|
165
|
+
act(() => {
|
|
166
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
167
|
+
result.current.handleScrollEndDrag(dragEvent(showY))
|
|
168
|
+
})
|
|
169
|
+
expect(scrollTo).toHaveBeenCalledTimes(1) // the initial animated call
|
|
170
|
+
|
|
171
|
+
act(() => {
|
|
172
|
+
jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS)
|
|
173
|
+
})
|
|
174
|
+
expect(scrollTo).toHaveBeenLastCalledWith(showY, false) // first non-animated retry, still short of target
|
|
175
|
+
|
|
176
|
+
// The retry "lands" — simulate onScroll confirming the real position.
|
|
177
|
+
contextValue.scrollPosition.value = showY
|
|
178
|
+
act(() => {
|
|
179
|
+
jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS)
|
|
180
|
+
})
|
|
181
|
+
scrollTo.mockClear()
|
|
182
|
+
act(() => {
|
|
183
|
+
jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS * 5)
|
|
184
|
+
})
|
|
185
|
+
expect(scrollTo).not.toHaveBeenCalled() // verification stopped once confirmed
|
|
186
|
+
unmount()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('gives up after exceeding the max retry attempts rather than retrying forever', () => {
|
|
190
|
+
const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
191
|
+
contextValue.scrollPosition.value = hideY // never reaches showY, no matter how many retries
|
|
192
|
+
act(() => {
|
|
193
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
194
|
+
result.current.handleScrollEndDrag(dragEvent(showY))
|
|
195
|
+
})
|
|
196
|
+
act(() => {
|
|
197
|
+
jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS + SNAP_VERIFY_RETRY_DELAY_MS * (SNAP_VERIFY_MAX_ATTEMPTS + 1))
|
|
198
|
+
})
|
|
199
|
+
// 1 initial animated call + SNAP_VERIFY_MAX_ATTEMPTS non-animated retries, then it gives up.
|
|
200
|
+
expect(scrollTo).toHaveBeenCalledTimes(1 + SNAP_VERIFY_MAX_ATTEMPTS)
|
|
201
|
+
|
|
202
|
+
scrollTo.mockClear()
|
|
203
|
+
act(() => {
|
|
204
|
+
jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS * 3)
|
|
205
|
+
})
|
|
206
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
207
|
+
unmount()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('cancels a pending verification when a new drag begins', () => {
|
|
211
|
+
const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
212
|
+
contextValue.scrollPosition.value = hideY
|
|
213
|
+
act(() => {
|
|
214
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
215
|
+
result.current.handleScrollEndDrag(dragEvent(showY))
|
|
216
|
+
})
|
|
217
|
+
scrollTo.mockClear()
|
|
218
|
+
act(() => {
|
|
219
|
+
result.current.handleScrollBeginDrag(dragEvent(showY))
|
|
220
|
+
})
|
|
221
|
+
act(() => {
|
|
222
|
+
jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS * 3)
|
|
223
|
+
})
|
|
224
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
225
|
+
unmount()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('handleMomentumScrollEnd auto-closes when momentum alone carried it into the zone with no drag-committed target', () => {
|
|
229
|
+
const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
230
|
+
act(() => {
|
|
231
|
+
result.current.handleMomentumScrollEnd(dragEvent(hideY - 10))
|
|
232
|
+
})
|
|
233
|
+
expect(scrollTo).toHaveBeenCalledWith(hideY, true)
|
|
234
|
+
unmount()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('handleMomentumScrollEnd defers to an already-pending drag-committed target instead of double-correcting', () => {
|
|
238
|
+
const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
|
|
239
|
+
contextValue.scrollPosition.value = hideY
|
|
240
|
+
act(() => {
|
|
241
|
+
result.current.handleScrollBeginDrag(dragEvent(hideY))
|
|
242
|
+
result.current.handleScrollEndDrag(dragEvent(showY))
|
|
243
|
+
})
|
|
244
|
+
scrollTo.mockClear()
|
|
245
|
+
act(() => {
|
|
246
|
+
result.current.handleMomentumScrollEnd(dragEvent(hideY - 10))
|
|
247
|
+
})
|
|
248
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
249
|
+
unmount()
|
|
250
|
+
})
|
|
251
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { CustomListProps } from './CustomList'
|
|
|
2
2
|
export { CustomList } from './CustomList'
|
|
3
3
|
export type { FlatListProps } from './FlatList'
|
|
4
4
|
export { FlatList } from './FlatList'
|
|
5
|
+
export type { ChipProps } from './internal/ScrollViewChip'
|
|
5
6
|
export type { PullSearchHandle, PullSearchProps } from './PullSearch'
|
|
6
7
|
export { PullSearch } from './PullSearch'
|
|
7
8
|
export { scrollViewActions, scrollViewReducer } from './redux/scrollViewSlice'
|
|
@@ -1,20 +1,36 @@
|
|
|
1
|
+
import { Chip, type ChipProps as AutoPaperChipProps } from '@rific/auto-paper'
|
|
2
|
+
import { type ReactNode } from 'react'
|
|
1
3
|
import { StyleSheet, type ViewStyle } from 'react-native'
|
|
2
|
-
import { Chip } from 'react-native-paper'
|
|
3
4
|
import Animated, { type AnimatedStyle } from 'react-native-reanimated'
|
|
4
5
|
|
|
6
|
+
export type ChipProps = Omit<AutoPaperChipProps, 'compact' | 'icon' | 'onPress'> & {
|
|
7
|
+
label?: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
type Props = {
|
|
11
|
+
chipProps?: ChipProps
|
|
6
12
|
isHorizontal?: boolean
|
|
13
|
+
onChipPress?: () => void
|
|
7
14
|
onPress: () => void
|
|
8
15
|
style: AnimatedStyle<ViewStyle>
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
export const ScrollViewChip = ({ isHorizontal, onPress, style }: Props) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
export const ScrollViewChip = ({ chipProps, isHorizontal, onChipPress, onPress, style }: Props) => {
|
|
19
|
+
const { label, style: chipStyle, ...restChipProps } = (chipProps ?? {}) as Partial<ChipProps>
|
|
20
|
+
const handlePress = onChipPress
|
|
21
|
+
? () => {
|
|
22
|
+
onPress()
|
|
23
|
+
onChipPress()
|
|
24
|
+
}
|
|
25
|
+
: onPress
|
|
26
|
+
return (
|
|
27
|
+
<Animated.View style={[styles.chip, style]}>
|
|
28
|
+
<Chip compact {...restChipProps} icon={isHorizontal ? 'chevron-left' : 'chevron-up'} onPress={handlePress} style={[styles.chipInner, chipStyle]}>
|
|
29
|
+
{label ?? (isHorizontal ? 'Start' : 'Top')}
|
|
30
|
+
</Chip>
|
|
31
|
+
</Animated.View>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
const styles = StyleSheet.create({
|
|
20
36
|
chip: { alignItems: 'center', left: 0, position: 'absolute', right: 0, zIndex: 3 },
|
|
@@ -3,34 +3,49 @@ import type { SharedValue } from 'react-native-reanimated'
|
|
|
3
3
|
import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
4
|
|
|
5
5
|
import { ScrollViewContext } from '../ScrollViewContext'
|
|
6
|
+
import { REMOUNT_SYNC_TOLERANCE } from '../useScrollInit'
|
|
6
7
|
|
|
7
8
|
type UseScrollHandlerOptions = {
|
|
9
|
+
capturedGeneration: SharedValue<number>
|
|
8
10
|
chipHidden: SharedValue<number>
|
|
11
|
+
chipThreshold?: number
|
|
9
12
|
footerFixed: boolean
|
|
10
13
|
headerFixed: boolean
|
|
11
14
|
isHorizontal?: boolean
|
|
12
|
-
|
|
15
|
+
listGeneration: SharedValue<number>
|
|
16
|
+
onRemountSyncRetry?: (currentY: number) => void
|
|
17
|
+
onRemountSynced?: (y: number) => void
|
|
18
|
+
remountSyncTarget?: SharedValue<number | null>
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
export function useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal,
|
|
21
|
+
export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget }: UseScrollHandlerOptions) {
|
|
16
22
|
const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
17
23
|
const snapUpAccum = useSharedValue(0)
|
|
18
24
|
const fromBottomBounce = useSharedValue(false)
|
|
19
|
-
const isMomentum = useSharedValue(false)
|
|
20
|
-
const momentumPullSearchFired = useSharedValue(false)
|
|
21
25
|
|
|
22
26
|
return useAnimatedScrollHandler(
|
|
23
27
|
{
|
|
24
28
|
onScroll: ({ contentOffset: { x, y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
|
|
25
29
|
'worklet'
|
|
30
|
+
if (remountSyncTarget && remountSyncTarget.value !== null) {
|
|
31
|
+
const target = remountSyncTarget.value
|
|
32
|
+
if (Math.abs(y - target) < REMOUNT_SYNC_TOLERANCE) {
|
|
33
|
+
remountSyncTarget.value = null
|
|
34
|
+
if (onRemountSynced) runOnJS(onRemountSynced)(y)
|
|
35
|
+
} else if (onRemountSyncRetry) {
|
|
36
|
+
runOnJS(onRemountSyncRetry)(y)
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (listGeneration.value !== capturedGeneration.value) return
|
|
26
41
|
if (isHorizontal) {
|
|
27
42
|
scrollPosition.value = x
|
|
28
|
-
chipHidden.value = x <
|
|
43
|
+
chipHidden.value = x < chipThreshold ? 1 : 0
|
|
29
44
|
return
|
|
30
45
|
}
|
|
31
46
|
const delta = y - scrollPosition.value
|
|
32
47
|
scrollPosition.value = y
|
|
33
|
-
chipHidden.value = y <
|
|
48
|
+
chipHidden.value = y < chipThreshold ? 1 : 0
|
|
34
49
|
const maxScroll = contentHeight - layoutHeight
|
|
35
50
|
if (maxScroll > 0 && y >= maxScroll) {
|
|
36
51
|
fromBottomBounce.value = true
|
|
@@ -38,13 +53,6 @@ export function useScrollHandler({ chipHidden, footerFixed, headerFixed, isHoriz
|
|
|
38
53
|
} else if (fromBottomBounce.value) {
|
|
39
54
|
fromBottomBounce.value = false
|
|
40
55
|
}
|
|
41
|
-
if (isMomentum.value && pullSearchHeightShared.value > 0 && !momentumPullSearchFired.value) {
|
|
42
|
-
const hideY = -headerHeightShared.value + pullSearchHeightShared.value
|
|
43
|
-
if (y < hideY) {
|
|
44
|
-
momentumPullSearchFired.value = true
|
|
45
|
-
if (onPullSearchZoneEnter) runOnJS(onPullSearchZoneEnter)()
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
56
|
const snapHeader = snapBackHeaderShared.value && !headerFixed
|
|
49
57
|
const snapFooter = snapBackFooterShared.value && !footerFixed
|
|
50
58
|
if (snapHeader || snapFooter) {
|
|
@@ -70,16 +78,9 @@ export function useScrollHandler({ chipHidden, footerFixed, headerFixed, isHoriz
|
|
|
70
78
|
},
|
|
71
79
|
onBeginDrag: () => {
|
|
72
80
|
'worklet'
|
|
73
|
-
isMomentum.value = false
|
|
74
|
-
momentumPullSearchFired.value = false
|
|
75
81
|
fromBottomBounce.value = false
|
|
76
82
|
snapUpAccum.value = 0
|
|
77
83
|
},
|
|
78
|
-
onMomentumBegin: () => {
|
|
79
|
-
'worklet'
|
|
80
|
-
isMomentum.value = true
|
|
81
|
-
momentumPullSearchFired.value = false
|
|
82
|
-
},
|
|
83
84
|
onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
|
|
84
85
|
'worklet'
|
|
85
86
|
if (isHorizontal) return
|
|
@@ -90,12 +91,10 @@ export function useScrollHandler({ chipHidden, footerFixed, headerFixed, isHoriz
|
|
|
90
91
|
},
|
|
91
92
|
onMomentumEnd: () => {
|
|
92
93
|
'worklet'
|
|
93
|
-
isMomentum.value = false
|
|
94
|
-
momentumPullSearchFired.value = false
|
|
95
94
|
fromBottomBounce.value = false
|
|
96
95
|
snapUpAccum.value = 0
|
|
97
96
|
}
|
|
98
97
|
},
|
|
99
|
-
[headerFixed, footerFixed, isHorizontal,
|
|
98
|
+
[capturedGeneration, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
|
|
100
99
|
)
|
|
101
100
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type RefObject, useCallback, useContext, useRef } from 'react'
|
|
2
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
|
|
3
|
+
import type { SharedValue } from 'react-native-reanimated'
|
|
4
|
+
import { withTiming } from 'react-native-reanimated'
|
|
5
|
+
|
|
6
|
+
import { ScrollViewContext } from '../ScrollViewContext'
|
|
7
|
+
|
|
8
|
+
const REMOUNT_RETRY_TOLERANCE = 1
|
|
9
|
+
const REMOUNT_RETRY_MAX_ATTEMPTS = 8
|
|
10
|
+
|
|
11
|
+
type UseScrollHandlerJSOptions = {
|
|
12
|
+
capturedGeneration: RefObject<number>
|
|
13
|
+
chipHidden: SharedValue<number>
|
|
14
|
+
chipThreshold?: number
|
|
15
|
+
footerFixed: boolean
|
|
16
|
+
headerFixed: boolean
|
|
17
|
+
isHorizontal?: boolean
|
|
18
|
+
jsListGeneration: RefObject<number>
|
|
19
|
+
remountTarget?: number | null
|
|
20
|
+
scrollTo?: (offset: number, animated: boolean) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ScrollHandlerJS = {
|
|
24
|
+
onMomentumScrollBegin: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
25
|
+
onMomentumScrollEnd: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
26
|
+
onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
27
|
+
onScrollBeginDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
28
|
+
onScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Plain JS-thread scroll handler for a non-Animated scrollable. Reanimated shared values are
|
|
32
|
+
// readable/writable from any thread, so driving them via a regular onScroll callback (rather than
|
|
33
|
+
// a useAnimatedScrollHandler worklet, which requires an Animated-wrapped component to attach to at
|
|
34
|
+
// the native level) works fine here — the header/footer animations just pick up the change on
|
|
35
|
+
// their next UI-thread frame. This trades a small amount of latency for a plain scrollable that
|
|
36
|
+
// reliably honors the initial `contentOffset` prop on (re)mount, which Animated.FlatList does not.
|
|
37
|
+
export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget = null, scrollTo }: UseScrollHandlerJSOptions): ScrollHandlerJS {
|
|
38
|
+
const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
39
|
+
const snapUpAccum = useRef(0)
|
|
40
|
+
const fromBottomBounce = useRef(false)
|
|
41
|
+
// On a remount, the very first native position after mount isn't reliable — Fabric can skip
|
|
42
|
+
// reapplying `contentOffset` when the target is numerically unchanged from before, and native
|
|
43
|
+
// scroll settling can overshoot or undershoot it. Rather than trust either the declarative prop
|
|
44
|
+
// or a single imperative scrollTo call, keep correcting (without hiding anything — content stays
|
|
45
|
+
// visible throughout) until an onScroll event actually confirms the intended resting position.
|
|
46
|
+
const pendingRemountTarget = useRef(remountTarget)
|
|
47
|
+
const remountAttempts = useRef(0)
|
|
48
|
+
|
|
49
|
+
const onScroll = useCallback(
|
|
50
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
51
|
+
if (jsListGeneration.current !== capturedGeneration.current) return
|
|
52
|
+
const {
|
|
53
|
+
contentOffset: { x, y },
|
|
54
|
+
contentSize: { height: contentHeight },
|
|
55
|
+
layoutMeasurement: { height: layoutHeight }
|
|
56
|
+
} = event.nativeEvent
|
|
57
|
+
if (pendingRemountTarget.current !== null) {
|
|
58
|
+
const target = pendingRemountTarget.current
|
|
59
|
+
if (Math.abs(y - target) < REMOUNT_RETRY_TOLERANCE) {
|
|
60
|
+
pendingRemountTarget.current = null
|
|
61
|
+
} else {
|
|
62
|
+
remountAttempts.current += 1
|
|
63
|
+
if (remountAttempts.current > REMOUNT_RETRY_MAX_ATTEMPTS) {
|
|
64
|
+
pendingRemountTarget.current = null
|
|
65
|
+
} else {
|
|
66
|
+
scrollTo?.(target, false)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (isHorizontal) {
|
|
72
|
+
scrollPosition.value = x
|
|
73
|
+
chipHidden.value = x < chipThreshold ? 1 : 0
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const delta = y - scrollPosition.value
|
|
77
|
+
scrollPosition.value = y
|
|
78
|
+
chipHidden.value = y < chipThreshold ? 1 : 0
|
|
79
|
+
const maxScroll = contentHeight - layoutHeight
|
|
80
|
+
if (maxScroll > 0 && y >= maxScroll) {
|
|
81
|
+
fromBottomBounce.current = true
|
|
82
|
+
snapUpAccum.current = 0
|
|
83
|
+
} else if (fromBottomBounce.current) {
|
|
84
|
+
fromBottomBounce.current = false
|
|
85
|
+
}
|
|
86
|
+
const snapHeader = snapBackHeaderShared.value && !headerFixed
|
|
87
|
+
const snapFooter = snapBackFooterShared.value && !footerFixed
|
|
88
|
+
if (snapHeader || snapFooter) {
|
|
89
|
+
if (y <= -headerHeightShared.value) {
|
|
90
|
+
snapUpAccum.current = 0
|
|
91
|
+
if (snapHeader) headerOffset.value = 0
|
|
92
|
+
if (snapFooter) footerOffset.value = 0
|
|
93
|
+
} else if (delta > 0) {
|
|
94
|
+
snapUpAccum.current = 0
|
|
95
|
+
if (y >= -headerHeightShared.value + pullSearchHeightShared.value) {
|
|
96
|
+
if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
|
|
97
|
+
if (snapFooter) footerOffset.value = Math.max(0, Math.min(footerHeightShared.value, footerOffset.value + delta))
|
|
98
|
+
}
|
|
99
|
+
} else if (delta < 0 && !fromBottomBounce.current) {
|
|
100
|
+
if (y >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.current -= delta
|
|
101
|
+
if (snapUpAccum.current >= 10) {
|
|
102
|
+
snapUpAccum.current = 999
|
|
103
|
+
if (snapHeader) headerOffset.value = withTiming(0, { duration: 200 })
|
|
104
|
+
if (snapFooter) footerOffset.value = withTiming(0, { duration: 200 })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[capturedGeneration, chipHidden, chipThreshold, footerFixed, footerHeightShared, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
113
|
+
fromBottomBounce.current = false
|
|
114
|
+
snapUpAccum.current = 0
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
const onMomentumScrollBegin = useCallback(() => {}, [])
|
|
118
|
+
|
|
119
|
+
const onScrollEndDrag = useCallback(
|
|
120
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
121
|
+
if (isHorizontal) return
|
|
122
|
+
const {
|
|
123
|
+
contentOffset: { y },
|
|
124
|
+
contentSize: { height: contentHeight },
|
|
125
|
+
layoutMeasurement: { height: layoutHeight }
|
|
126
|
+
} = event.nativeEvent
|
|
127
|
+
if (y >= contentHeight - layoutHeight - 10) {
|
|
128
|
+
fromBottomBounce.current = true
|
|
129
|
+
snapUpAccum.current = 0
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[isHorizontal]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const onMomentumScrollEnd = useCallback(() => {
|
|
136
|
+
fromBottomBounce.current = false
|
|
137
|
+
snapUpAccum.current = 0
|
|
138
|
+
}, [])
|
|
139
|
+
|
|
140
|
+
return { onMomentumScrollBegin, onMomentumScrollEnd, onScroll, onScrollBeginDrag, onScrollEndDrag }
|
|
141
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useContext, useEffect, useMemo } from 'react'
|
|
1
|
+
import { useContext, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'
|
|
3
3
|
import { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
4
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
@@ -29,15 +29,19 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
29
29
|
pullSearchHeightShared.value = pullSearchHeight ?? 0
|
|
30
30
|
}, [pullSearchHeight, pullSearchHeightShared])
|
|
31
31
|
|
|
32
|
-
// First render (headerHeight===0): flex:1
|
|
33
|
-
//
|
|
34
|
-
|
|
32
|
+
// First render (headerHeight===0): flex:1, hidden until header measures so the key=0→1
|
|
33
|
+
// transition (and contentOffset change) never appears as a visible jump.
|
|
34
|
+
// After header measures: absoluteFill with correct contentOffset — then reveal.
|
|
35
|
+
const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: 0 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, style])
|
|
35
36
|
|
|
36
|
-
const contentInset = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
|
|
37
|
+
const contentInset = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight ?? 0 }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
|
|
37
38
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
39
|
+
// Use hideY offset only when this component mounted with headerHeight already known (mode switch).
|
|
40
|
+
// On initial load, headerHeight is null at mount so we start at showY and let useScrollInit
|
|
41
|
+
// call scrollTo after the phase transition. On mode switch, headerHeight is already set so we
|
|
42
|
+
// can position the FlatList at hideY atomically via contentOffset — no scrollTo needed.
|
|
43
|
+
const [startedWithHeader] = useState(headerHeight !== null)
|
|
44
|
+
const contentOffset = useMemo(() => ({ x: 0, y: headerHeight !== null ? -headerHeight + (startedWithHeader && !isHorizontal && pullSearchHeight ? pullSearchHeight : 0) : 0 }), [headerHeight, isHorizontal, pullSearchHeight, startedWithHeader])
|
|
41
45
|
|
|
42
46
|
const chipHidden = useSharedValue(1)
|
|
43
47
|
const chipStyle = useAnimatedStyle(() => {
|
|
@@ -46,12 +50,13 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
46
50
|
return {
|
|
47
51
|
opacity: chipHidden.value ? withTiming(0) : withTiming(1),
|
|
48
52
|
pointerEvents,
|
|
49
|
-
top: Math.max(headerHeight, insets.top) + 4,
|
|
53
|
+
top: Math.max(headerHeight ?? 0, insets.top) + 4,
|
|
50
54
|
transform: [{ translateX: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
|
-
const
|
|
54
|
-
const
|
|
57
|
+
const h = headerHeight ?? 0
|
|
58
|
+
const slide = headerFixed ? 0 : snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
|
|
59
|
+
const top = Math.max(h - slide, insets.top) + 4
|
|
55
60
|
return {
|
|
56
61
|
opacity: chipHidden.value ? withTiming(0) : withTiming(1),
|
|
57
62
|
pointerEvents,
|