@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/src/SectionList.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { memo, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
1
|
+
import { memo, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
2
|
import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
|
|
3
3
|
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
|
-
import Animated, {
|
|
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
10
|
import { useStickyHeaders } from './internal/useStickyHeaders'
|
|
11
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
11
12
|
import { useScrollInit } from './useScrollInit'
|
|
12
13
|
|
|
13
|
-
const AnimatedSectionList = createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
|
|
14
|
+
const AnimatedSectionList = Animated.createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
|
|
14
15
|
|
|
15
16
|
export type SectionListProps<ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }> = Omit<RNSectionListProps<ItemT, SectionT>, 'horizontal'> & {
|
|
16
17
|
chipProps?: ChipProps
|
|
@@ -34,6 +35,29 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
34
35
|
const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
|
|
35
36
|
|
|
36
37
|
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
|
|
38
|
+
const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
|
|
39
|
+
// -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
|
|
40
|
+
// corrects it before any scroll event could observe the placeholder.
|
|
41
|
+
const capturedGeneration = useSharedValue(-1)
|
|
42
|
+
// Layout effect (not passive effect): React guarantees all layout-effect cleanups for fibers
|
|
43
|
+
// removed in a commit run before any layout-effect setups for fibers added in that same commit,
|
|
44
|
+
// but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
|
|
45
|
+
// instance's unmount and the incoming instance's mount land in the same commit — with passive
|
|
46
|
+
// effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
|
|
47
|
+
// capturing a stale generation and silently disabling this instance's onScroll handling forever.
|
|
48
|
+
// Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
|
|
49
|
+
// on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
|
|
50
|
+
// there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
|
|
51
|
+
// imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
|
|
52
|
+
// own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
|
|
53
|
+
// only be queued after this component finishes mounting.
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const generation = listGeneration.value
|
|
56
|
+
runOnUI((gen: number) => {
|
|
57
|
+
'worklet'
|
|
58
|
+
capturedGeneration.value = gen
|
|
59
|
+
})(generation)
|
|
60
|
+
}, [capturedGeneration, listGeneration])
|
|
37
61
|
|
|
38
62
|
const usesCustomSticky = stickySectionHeadersEnabled && renderSectionHeader != null
|
|
39
63
|
const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
|
|
@@ -70,6 +94,8 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
70
94
|
}
|
|
71
95
|
}, [usesCustomSticky, renderSectionHeader, sections, measureHeader])
|
|
72
96
|
|
|
97
|
+
useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
|
|
98
|
+
|
|
73
99
|
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
74
100
|
scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated })
|
|
75
101
|
}, [])
|
|
@@ -120,12 +146,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
120
146
|
|
|
121
147
|
const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
122
148
|
|
|
123
|
-
const
|
|
124
|
-
if (!pullSearchHeight) return
|
|
125
|
-
scrollTo(-contentInset.top + pullSearchHeight, true)
|
|
126
|
-
}, [contentInset.top, pullSearchHeight, scrollTo])
|
|
127
|
-
|
|
128
|
-
const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
149
|
+
const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration })
|
|
129
150
|
|
|
130
151
|
const handleScrollToTop = useCallback(() => {
|
|
131
152
|
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
|
@@ -147,7 +168,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
147
168
|
const content = (
|
|
148
169
|
<View style={containerStyle}>
|
|
149
170
|
<AnimatedSectionList
|
|
150
|
-
key={headerHeight > 0 ? 1 : 0}
|
|
171
|
+
key={headerHeight !== null && headerHeight > 0 ? 1 : 0}
|
|
151
172
|
{...passThroughProps}
|
|
152
173
|
contentContainerStyle={contentContainerStyle}
|
|
153
174
|
contentInset={contentInset}
|
|
@@ -3,27 +3,41 @@ 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>
|
|
9
11
|
chipThreshold?: number
|
|
10
12
|
footerFixed: boolean
|
|
11
13
|
headerFixed: boolean
|
|
12
14
|
isHorizontal?: boolean
|
|
13
|
-
|
|
15
|
+
listGeneration: SharedValue<number>
|
|
16
|
+
onRemountSyncRetry?: (currentY: number) => void
|
|
17
|
+
onRemountSynced?: (y: number) => void
|
|
18
|
+
remountSyncTarget?: SharedValue<number | null>
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal,
|
|
21
|
+
export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget }: UseScrollHandlerOptions) {
|
|
17
22
|
const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
18
23
|
const snapUpAccum = useSharedValue(0)
|
|
19
24
|
const fromBottomBounce = useSharedValue(false)
|
|
20
|
-
const isMomentum = useSharedValue(false)
|
|
21
|
-
const momentumPullSearchFired = useSharedValue(false)
|
|
22
25
|
|
|
23
26
|
return useAnimatedScrollHandler(
|
|
24
27
|
{
|
|
25
28
|
onScroll: ({ contentOffset: { x, y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
|
|
26
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
|
|
27
41
|
if (isHorizontal) {
|
|
28
42
|
scrollPosition.value = x
|
|
29
43
|
chipHidden.value = x < chipThreshold ? 1 : 0
|
|
@@ -39,13 +53,6 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
|
|
|
39
53
|
} else if (fromBottomBounce.value) {
|
|
40
54
|
fromBottomBounce.value = false
|
|
41
55
|
}
|
|
42
|
-
if (isMomentum.value && pullSearchHeightShared.value > 0 && !momentumPullSearchFired.value) {
|
|
43
|
-
const hideY = -headerHeightShared.value + pullSearchHeightShared.value
|
|
44
|
-
if (y < hideY) {
|
|
45
|
-
momentumPullSearchFired.value = true
|
|
46
|
-
if (onPullSearchZoneEnter) runOnJS(onPullSearchZoneEnter)()
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
56
|
const snapHeader = snapBackHeaderShared.value && !headerFixed
|
|
50
57
|
const snapFooter = snapBackFooterShared.value && !footerFixed
|
|
51
58
|
if (snapHeader || snapFooter) {
|
|
@@ -71,16 +78,9 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
|
|
|
71
78
|
},
|
|
72
79
|
onBeginDrag: () => {
|
|
73
80
|
'worklet'
|
|
74
|
-
isMomentum.value = false
|
|
75
|
-
momentumPullSearchFired.value = false
|
|
76
81
|
fromBottomBounce.value = false
|
|
77
82
|
snapUpAccum.value = 0
|
|
78
83
|
},
|
|
79
|
-
onMomentumBegin: () => {
|
|
80
|
-
'worklet'
|
|
81
|
-
isMomentum.value = true
|
|
82
|
-
momentumPullSearchFired.value = false
|
|
83
|
-
},
|
|
84
84
|
onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
|
|
85
85
|
'worklet'
|
|
86
86
|
if (isHorizontal) return
|
|
@@ -91,12 +91,10 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
|
|
|
91
91
|
},
|
|
92
92
|
onMomentumEnd: () => {
|
|
93
93
|
'worklet'
|
|
94
|
-
isMomentum.value = false
|
|
95
|
-
momentumPullSearchFired.value = false
|
|
96
94
|
fromBottomBounce.value = false
|
|
97
95
|
snapUpAccum.value = 0
|
|
98
96
|
}
|
|
99
97
|
},
|
|
100
|
-
[headerFixed, footerFixed, isHorizontal, chipThreshold,
|
|
98
|
+
[capturedGeneration, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
|
|
101
99
|
)
|
|
102
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,
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
|
2
|
-
|
|
3
1
|
import { defaultScrollViewSettings, type ScrollViewSettings } from '../ScrollViewSettingsContext'
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
})
|
|
3
|
+
// Hand-rolled slice — no @reduxjs/toolkit dependency. Action types and creator
|
|
4
|
+
// behavior match the previous createSlice implementation exactly, so this works
|
|
5
|
+
// with RTK stores, vanilla Redux, or any reducer-based state container.
|
|
6
|
+
type PayloadAction<P> = { payload: P; type: string }
|
|
7
|
+
|
|
8
|
+
const createAction = <P>(type: string) => {
|
|
9
|
+
const actionCreator = (payload: P): PayloadAction<P> => ({ payload, type })
|
|
10
|
+
actionCreator.type = type
|
|
11
|
+
actionCreator.match = (action: { type: string }): action is PayloadAction<P> => action.type === type
|
|
12
|
+
return actionCreator
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const initialize = createAction<ScrollViewSettings>('scrollView/initialize')
|
|
16
|
+
|
|
17
|
+
export const scrollViewActions = { initialize }
|
|
12
18
|
|
|
13
|
-
export const
|
|
14
|
-
|
|
19
|
+
export const scrollViewReducer = (state: ScrollViewSettings = defaultScrollViewSettings, action: { type: string }): ScrollViewSettings => {
|
|
20
|
+
if (initialize.match(action)) return action.payload
|
|
21
|
+
return state
|
|
22
|
+
}
|