@rific/scroll-view 0.1.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 +319 -0
- package/dist/index.d.mts +210 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +2760 -0
- package/dist/index.mjs +2709 -0
- package/package.json +105 -0
- package/src/CustomList.tsx +127 -0
- package/src/FlatList.tsx +139 -0
- package/src/PullSearch.tsx +99 -0
- package/src/ScrollView.tsx +89 -0
- package/src/ScrollViewContext.ts +50 -0
- package/src/ScrollViewFooter.tsx +48 -0
- package/src/ScrollViewHeader.tsx +159 -0
- package/src/ScrollViewProvider.tsx +62 -0
- package/src/ScrollViewSettingsContext.ts +27 -0
- package/src/ScrollViewSettingsProvider.tsx +31 -0
- package/src/SectionList.tsx +195 -0
- package/src/__mocks__/auto-paper.ts +8 -0
- package/src/__mocks__/flash-list.ts +20 -0
- package/src/__mocks__/react-native-gesture-handler.ts +13 -0
- package/src/__mocks__/react-native-keyboard-controller.ts +11 -0
- package/src/__mocks__/react-native-paper.ts +22 -0
- package/src/__mocks__/react-native-reanimated.ts +30 -0
- package/src/__mocks__/react-native-safe-area-context.ts +2 -0
- package/src/__mocks__/react-native.ts +30 -0
- package/src/__tests__/CustomList.test.tsx +61 -0
- package/src/__tests__/FlatList.test.tsx +52 -0
- package/src/__tests__/PullSearch.test.tsx +38 -0
- package/src/__tests__/ScrollView.test.tsx +23 -0
- package/src/__tests__/ScrollViewHeader.test.tsx +28 -0
- package/src/__tests__/ScrollViewProvider.test.tsx +65 -0
- package/src/__tests__/SectionList.test.tsx +88 -0
- package/src/index.ts +26 -0
- package/src/internal/FAB.tsx +6 -0
- package/src/internal/HorizontalDots.tsx +99 -0
- package/src/internal/RefreshControl.tsx +5 -0
- package/src/internal/ScrollViewChip.tsx +22 -0
- package/src/internal/useScrollHandler.ts +101 -0
- package/src/internal/useScrollList.ts +64 -0
- package/src/internal/useStickyHeaders.ts +149 -0
- package/src/out.txt +61 -0
- package/src/redux/scrollViewSlice.ts +14 -0
- package/src/useKeyboardInset.ts +21 -0
- package/src/useScrollInit.ts +112 -0
- package/src/useScrollView.ts +8 -0
- package/src/useScrollViewSettings.ts +5 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo } from 'react'
|
|
2
|
+
import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'
|
|
3
|
+
import { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
4
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
5
|
+
|
|
6
|
+
import { ScrollViewContext } from '../ScrollViewContext'
|
|
7
|
+
import { useKeyboardInset } from '../useKeyboardInset'
|
|
8
|
+
|
|
9
|
+
const CHIP_SLIDE = 48
|
|
10
|
+
|
|
11
|
+
export type UseScrollListOptions = {
|
|
12
|
+
footerFixed?: boolean
|
|
13
|
+
headerFixed?: boolean
|
|
14
|
+
isHorizontal?: boolean
|
|
15
|
+
keyboardAware?: boolean
|
|
16
|
+
pullSearchHeight?: number
|
|
17
|
+
style?: StyleProp<ViewStyle>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
|
|
21
|
+
const insets = useSafeAreaInsets()
|
|
22
|
+
const keyboardHeight = useKeyboardInset()
|
|
23
|
+
const { footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
24
|
+
|
|
25
|
+
const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
|
|
26
|
+
const footerFixed = isHorizontal ? true : (footerFixedProp ?? contextFooterFixed)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
pullSearchHeightShared.value = pullSearchHeight ?? 0
|
|
30
|
+
}, [pullSearchHeight, pullSearchHeightShared])
|
|
31
|
+
|
|
32
|
+
// First render (headerHeight===0): flex:1 so the list sits naturally below the in-flow header.
|
|
33
|
+
// After header measures: absoluteFill with contentOffset to match the same visual position.
|
|
34
|
+
const containerStyle = useMemo(() => (headerHeight === 0 ? [{ flex: 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, style])
|
|
35
|
+
|
|
36
|
+
const contentInset = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
|
|
37
|
+
|
|
38
|
+
// Synchronously positions the scroll view to match the in-flow layout from the first render.
|
|
39
|
+
// Fabric applies this atomically with the containerStyle switch, so there is no visible jump.
|
|
40
|
+
const contentOffset = useMemo(() => ({ x: 0, y: -contentInset.top }), [contentInset.top])
|
|
41
|
+
|
|
42
|
+
const chipHidden = useSharedValue(1)
|
|
43
|
+
const chipStyle = useAnimatedStyle(() => {
|
|
44
|
+
const pointerEvents = chipHidden.value ? ('none' as const) : ('box-none' as const)
|
|
45
|
+
if (isHorizontal) {
|
|
46
|
+
return {
|
|
47
|
+
opacity: chipHidden.value ? withTiming(0) : withTiming(1),
|
|
48
|
+
pointerEvents,
|
|
49
|
+
top: Math.max(headerHeight, insets.top) + 4,
|
|
50
|
+
transform: [{ translateX: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const slide = headerFixed ? 0 : snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
|
|
54
|
+
const top = Math.max(headerHeight - slide, insets.top) + 4
|
|
55
|
+
return {
|
|
56
|
+
opacity: chipHidden.value ? withTiming(0) : withTiming(1),
|
|
57
|
+
pointerEvents,
|
|
58
|
+
top,
|
|
59
|
+
transform: [{ translateY: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
|
|
60
|
+
}
|
|
61
|
+
}, [isHorizontal, headerFixed, headerHeight, insets.top])
|
|
62
|
+
|
|
63
|
+
return { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed, headerHeight, insets, isHorizontal: isHorizontal ?? false }
|
|
64
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useContext, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
4
|
+
|
|
5
|
+
import { ScrollViewContext } from '../ScrollViewContext'
|
|
6
|
+
|
|
7
|
+
export type StickyHeadersApi = {
|
|
8
|
+
activeIndex: number
|
|
9
|
+
clipStyle: ReturnType<typeof useAnimatedStyle>
|
|
10
|
+
measureHeader: (index: number, y: number, height: number) => void
|
|
11
|
+
overlayStyle: ReturnType<typeof useAnimatedStyle>
|
|
12
|
+
resetPositions: (count: number) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useStickyHeaders(headerFixed: boolean, enabled: boolean): StickyHeadersApi {
|
|
16
|
+
const { headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared } = useContext(ScrollViewContext)
|
|
17
|
+
const insetsTop = useSafeAreaInsets().top
|
|
18
|
+
|
|
19
|
+
const sectionYPositions = useSharedValue<number[]>([])
|
|
20
|
+
const sectionHeights = useSharedValue<number[]>([])
|
|
21
|
+
const [activeIndex, setActiveIndex] = useState(-1)
|
|
22
|
+
// Mirrors activeIndex React state but lives on the UI thread for overlayStyle.
|
|
23
|
+
// Updated via useLayoutEffect so content and style always change together.
|
|
24
|
+
const contentIndexShared = useSharedValue(-1)
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
contentIndexShared.value = activeIndex
|
|
27
|
+
}, [activeIndex, contentIndexShared])
|
|
28
|
+
|
|
29
|
+
// JS-side source of truth — sectionYPositions is a snapshot pushed to the worklet.
|
|
30
|
+
// Reading sectionYPositions.value from JS returns the UI-thread's stale copy, so
|
|
31
|
+
// rapid async measure callbacks would overwrite each other; refs avoid that.
|
|
32
|
+
const positionsCache = useRef<number[]>([])
|
|
33
|
+
const heightsCache = useRef<number[]>([])
|
|
34
|
+
|
|
35
|
+
useAnimatedReaction(
|
|
36
|
+
() => {
|
|
37
|
+
if (!enabled) return -1
|
|
38
|
+
const positions = sectionYPositions.value
|
|
39
|
+
if (positions.length === 0) return -1
|
|
40
|
+
|
|
41
|
+
const hHeight = headerHeightShared.value
|
|
42
|
+
const scrollPos = scrollPosition.value
|
|
43
|
+
let stickyTop: number
|
|
44
|
+
if (headerFixed) {
|
|
45
|
+
stickyTop = hHeight
|
|
46
|
+
} else if (snapBackHeaderShared.value) {
|
|
47
|
+
stickyTop = Math.max(insetsTop, hHeight + headerOffset.value)
|
|
48
|
+
} else {
|
|
49
|
+
const effective = scrollPos + hHeight - pullSearchHeightShared.value
|
|
50
|
+
stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const threshold = scrollPos + stickyTop
|
|
54
|
+
let active = -1
|
|
55
|
+
for (let i = 0; i < positions.length; i++) {
|
|
56
|
+
if (positions[i] >= 0 && positions[i] < threshold) active = i
|
|
57
|
+
else if (positions[i] >= 0) break
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return active
|
|
61
|
+
},
|
|
62
|
+
(curr, prev) => {
|
|
63
|
+
if (curr !== prev) runOnJS(setActiveIndex)(curr)
|
|
64
|
+
},
|
|
65
|
+
[enabled, headerFixed, insetsTop]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Positions the clip container at stickyTop so overflow:hidden hides the outgoing
|
|
69
|
+
// header as it slides above the sticky line during a push transition.
|
|
70
|
+
const clipStyle = useAnimatedStyle(() => {
|
|
71
|
+
const hHeight = headerHeightShared.value
|
|
72
|
+
const scrollPos = scrollPosition.value
|
|
73
|
+
let stickyTop: number
|
|
74
|
+
if (!enabled || headerFixed) {
|
|
75
|
+
stickyTop = hHeight
|
|
76
|
+
} else if (snapBackHeaderShared.value) {
|
|
77
|
+
stickyTop = Math.max(insetsTop, hHeight + headerOffset.value)
|
|
78
|
+
} else {
|
|
79
|
+
const effective = scrollPos + hHeight - pullSearchHeightShared.value
|
|
80
|
+
stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective)
|
|
81
|
+
}
|
|
82
|
+
return { top: stickyTop }
|
|
83
|
+
}, [enabled, headerFixed, insetsTop])
|
|
84
|
+
|
|
85
|
+
// overlayStyle positions the header at top:0 relative to the clip container.
|
|
86
|
+
// The push animation uses 1:1 speed with pushThreshold = stickyTop + currentHeight
|
|
87
|
+
// (incoming header touches outgoing header's bottom). The clip container's
|
|
88
|
+
// overflow:hidden hides the outgoing header once it exits the top of the container.
|
|
89
|
+
const overlayStyle = useAnimatedStyle(() => {
|
|
90
|
+
const hidden = { opacity: 0, top: 0, transform: [{ translateY: 0 }] }
|
|
91
|
+
if (!enabled) return hidden
|
|
92
|
+
|
|
93
|
+
const index = contentIndexShared.value
|
|
94
|
+
const positions = sectionYPositions.value
|
|
95
|
+
if (index < 0 || index >= positions.length) return hidden
|
|
96
|
+
|
|
97
|
+
const hHeight = headerHeightShared.value
|
|
98
|
+
const scrollPos = scrollPosition.value
|
|
99
|
+
let stickyTop: number
|
|
100
|
+
if (headerFixed) {
|
|
101
|
+
stickyTop = hHeight
|
|
102
|
+
} else if (snapBackHeaderShared.value) {
|
|
103
|
+
stickyTop = Math.max(insetsTop, hHeight + headerOffset.value)
|
|
104
|
+
} else {
|
|
105
|
+
const effective = scrollPos + hHeight - pullSearchHeightShared.value
|
|
106
|
+
stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (positions[index] - scrollPos >= stickyTop) return hidden
|
|
110
|
+
|
|
111
|
+
const currentHeight = Math.max(0, sectionHeights.value[index] ?? 0)
|
|
112
|
+
const nextIndex = index + 1
|
|
113
|
+
let translateY = 0
|
|
114
|
+
if (currentHeight > 0 && nextIndex < positions.length && positions[nextIndex] >= 0) {
|
|
115
|
+
const nextScreenY = positions[nextIndex] - scrollPos
|
|
116
|
+
const pushThreshold = stickyTop + currentHeight
|
|
117
|
+
if (nextScreenY < pushThreshold) translateY = nextScreenY - pushThreshold
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { opacity: 1, top: 0, transform: [{ translateY }] }
|
|
121
|
+
}, [enabled, headerFixed, insetsTop])
|
|
122
|
+
|
|
123
|
+
// pageY is the view's window-coordinate Y from view.measure().
|
|
124
|
+
// contentY = pageY + contentOffset.y, since screenY = contentY - contentOffset.y.
|
|
125
|
+
const measureHeader = useCallback(
|
|
126
|
+
(index: number, pageY: number, height: number) => {
|
|
127
|
+
const contentY = pageY + scrollPosition.value
|
|
128
|
+
positionsCache.current[index] = contentY
|
|
129
|
+
sectionYPositions.value = positionsCache.current.slice()
|
|
130
|
+
|
|
131
|
+
heightsCache.current[index] = height
|
|
132
|
+
sectionHeights.value = heightsCache.current.slice()
|
|
133
|
+
},
|
|
134
|
+
[scrollPosition, sectionHeights, sectionYPositions]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const resetPositions = useCallback(
|
|
138
|
+
(count: number) => {
|
|
139
|
+
positionsCache.current = Array.from({ length: count }, () => -1)
|
|
140
|
+
sectionYPositions.value = positionsCache.current.slice()
|
|
141
|
+
|
|
142
|
+
heightsCache.current = Array.from({ length: count }, () => -1)
|
|
143
|
+
sectionHeights.value = heightsCache.current.slice()
|
|
144
|
+
},
|
|
145
|
+
[sectionHeights, sectionYPositions]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions }
|
|
149
|
+
}
|
package/src/out.txt
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
jay~/Developer/React-Native-Scroll-View% yalc publish && yalc push
|
|
2
|
+
Running prepublishOnly script: npm run build
|
|
3
|
+
|
|
4
|
+
> @rific/scroll-view@0.1.0 prepublishOnly
|
|
5
|
+
> npm run build
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
> @rific/scroll-view@0.1.0 build
|
|
9
|
+
> tsup src/index.ts --format cjs,esm --dts --clean
|
|
10
|
+
|
|
11
|
+
CLI Building entry: src/index.ts
|
|
12
|
+
CLI Using tsconfig: tsconfig.json
|
|
13
|
+
CLI tsup v8.5.1
|
|
14
|
+
CLI Target: es2020
|
|
15
|
+
CLI Cleaning output folder
|
|
16
|
+
CJS Build start
|
|
17
|
+
ESM Build start
|
|
18
|
+
CJS dist/index.js 103.45 KB
|
|
19
|
+
CJS ⚡️ Build success in 78ms
|
|
20
|
+
ESM dist/index.mjs 95.24 KB
|
|
21
|
+
ESM ⚡️ Build success in 78ms
|
|
22
|
+
DTS Build start
|
|
23
|
+
src/ScrollView.tsx(48,49): error TS2339: Property 'scrollTo' does not exist on type 'ScrollResponderMixin'.
|
|
24
|
+
|
|
25
|
+
Error: error occurred in dts build
|
|
26
|
+
at Worker.<anonymous> (/Users/jay/Developer/React-Native-Scroll-View/node_modules/tsup/dist/index.js:1545:26)
|
|
27
|
+
at Worker.emit (node:events:509:20)
|
|
28
|
+
at MessagePort.<anonymous> (node:internal/worker:333:53)
|
|
29
|
+
at [nodejs.internal.kHybridDispatch] (node:internal/event_target:843:20)
|
|
30
|
+
at MessagePort.<anonymous> (node:internal/per_context/messageport:23:28)
|
|
31
|
+
DTS Build error
|
|
32
|
+
yalc publish
|
|
33
|
+
|
|
34
|
+
Publish package in yalc local repo
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--version Show version number [boolean]
|
|
38
|
+
--help Show help [boolean]
|
|
39
|
+
--sig [boolean] [default: false]
|
|
40
|
+
--dev-mod [boolean] [default: true]
|
|
41
|
+
--workspace-resolve [default: true]
|
|
42
|
+
|
|
43
|
+
Error: Command failed: npm run prepublishOnly
|
|
44
|
+
at genericNodeError (node:internal/errors:998:15)
|
|
45
|
+
at wrappedFn (node:internal/errors:543:14)
|
|
46
|
+
at checkExecSyncError (node:child_process:925:11)
|
|
47
|
+
at Object.execSync (node:child_process:997:15)
|
|
48
|
+
at runPmScript (/opt/homebrew/lib/node_modules/yalc/src/publish.js:75:41)
|
|
49
|
+
at Array.forEach (<anonymous>)
|
|
50
|
+
at /opt/homebrew/lib/node_modules/yalc/src/publish.js:90:28
|
|
51
|
+
at step (/opt/homebrew/lib/node_modules/yalc/src/publish.js:44:23)
|
|
52
|
+
at Object.next (/opt/homebrew/lib/node_modules/yalc/src/publish.js:25:53)
|
|
53
|
+
at /opt/homebrew/lib/node_modules/yalc/src/publish.js:19:71 {
|
|
54
|
+
status: 1,
|
|
55
|
+
signal: null,
|
|
56
|
+
output: [ null, null, null ],
|
|
57
|
+
pid: 71344,
|
|
58
|
+
stdout: null,
|
|
59
|
+
stderr: null
|
|
60
|
+
}
|
|
61
|
+
jay~/Developer/React-Native-Scroll-View%
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
|
2
|
+
|
|
3
|
+
import { defaultScrollViewSettings, type ScrollViewSettings } from '../ScrollViewSettingsContext'
|
|
4
|
+
|
|
5
|
+
const slice = createSlice({
|
|
6
|
+
name: 'scrollView',
|
|
7
|
+
initialState: defaultScrollViewSettings,
|
|
8
|
+
reducers: {
|
|
9
|
+
initialize: (_state, action: PayloadAction<ScrollViewSettings>) => action.payload
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const scrollViewActions = slice.actions
|
|
14
|
+
export const scrollViewReducer = slice.reducer
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useKeyboardHandler } from 'react-native-keyboard-controller'
|
|
3
|
+
import { runOnJS } from 'react-native-reanimated'
|
|
4
|
+
|
|
5
|
+
export const useKeyboardInset = () => {
|
|
6
|
+
const [keyboardHeight, setKeyboardHeight] = useState(0)
|
|
7
|
+
useKeyboardHandler(
|
|
8
|
+
{
|
|
9
|
+
onMove: (e) => {
|
|
10
|
+
'worklet'
|
|
11
|
+
runOnJS(setKeyboardHeight)(e.height)
|
|
12
|
+
},
|
|
13
|
+
onEnd: (e) => {
|
|
14
|
+
'worklet'
|
|
15
|
+
runOnJS(setKeyboardHeight)(e.height)
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
[]
|
|
19
|
+
)
|
|
20
|
+
return keyboardHeight
|
|
21
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type ComponentType, createElement, type ReactElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
5
|
+
|
|
6
|
+
export type UseScrollInitOptions = {
|
|
7
|
+
listHeaderComponent?: ComponentType<object> | ReactElement | null
|
|
8
|
+
onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
9
|
+
onRefresh?: () => Promise<void> | void
|
|
10
|
+
onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
11
|
+
onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
12
|
+
pullSearchHeight?: number
|
|
13
|
+
scrollTo: (offset: number, animated: boolean) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type UseScrollInitResult = {
|
|
17
|
+
activeListHeader: ComponentType<object> | ReactElement | null | undefined
|
|
18
|
+
handleMomentumScrollEnd: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
19
|
+
handleScrollBeginDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
20
|
+
handleScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
21
|
+
hiddenHeader: ReactElement | null
|
|
22
|
+
pullSearchMinHeight: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollTo }: UseScrollInitOptions): UseScrollInitResult {
|
|
26
|
+
const { headerHeight, scrollPosition, setProgressing } = useContext(ScrollViewContext)
|
|
27
|
+
const hasPullSearch = pullSearchHeight !== undefined
|
|
28
|
+
const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch ? 'measuring' : 'ready')
|
|
29
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
30
|
+
const dragStartY = useRef<number | null>(null)
|
|
31
|
+
const deliberatelyOpened = useRef(false)
|
|
32
|
+
|
|
33
|
+
// Transition to 'ready' once both measurements are available
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (phase === 'ready' || !headerHeight || !pullSearchHeight) return
|
|
36
|
+
scrollPosition.value = -headerHeight + pullSearchHeight
|
|
37
|
+
setPhase('ready')
|
|
38
|
+
}, [phase, headerHeight, pullSearchHeight, scrollPosition])
|
|
39
|
+
|
|
40
|
+
// Effect A: scroll to natural top the first time headerHeight is measured
|
|
41
|
+
const headerInitialized = useRef(false)
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
if (!headerHeight || headerInitialized.current) return
|
|
44
|
+
headerInitialized.current = true
|
|
45
|
+
scrollPosition.value = -headerHeight
|
|
46
|
+
scrollTo(-headerHeight, false)
|
|
47
|
+
}, [headerHeight, scrollPosition, scrollTo])
|
|
48
|
+
|
|
49
|
+
// Effect B: scroll to hideY when pullSearch phase is ready (same Fabric commit as ListHeaderComponent addition)
|
|
50
|
+
const pullSearchInitialized = useRef(false)
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
if (!hasPullSearch || phase !== 'ready' || pullSearchInitialized.current || !headerHeight || !pullSearchHeight) return
|
|
53
|
+
pullSearchInitialized.current = true
|
|
54
|
+
scrollPosition.value = -headerHeight + pullSearchHeight
|
|
55
|
+
scrollTo(-headerHeight + pullSearchHeight, false)
|
|
56
|
+
}, [hasPullSearch, phase, headerHeight, pullSearchHeight, scrollPosition, scrollTo])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (pullSearchHeight) setProgressing(refreshing)
|
|
60
|
+
}, [pullSearchHeight, refreshing, setProgressing])
|
|
61
|
+
|
|
62
|
+
const handleScrollBeginDrag = useCallback(
|
|
63
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
64
|
+
deliberatelyOpened.current = false
|
|
65
|
+
if (pullSearchHeight) dragStartY.current = e.nativeEvent.contentOffset.y
|
|
66
|
+
externalScrollBeginDrag?.(e)
|
|
67
|
+
},
|
|
68
|
+
[externalScrollBeginDrag, pullSearchHeight]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const handleScrollEndDrag = useCallback(
|
|
72
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
73
|
+
if (pullSearchHeight && headerHeight) {
|
|
74
|
+
const y = e.nativeEvent.contentOffset.y
|
|
75
|
+
const showY = -headerHeight
|
|
76
|
+
const hideY = -headerHeight + pullSearchHeight
|
|
77
|
+
const pulledDown = (dragStartY.current ?? hideY) <= hideY
|
|
78
|
+
dragStartY.current = null
|
|
79
|
+
if (pulledDown && y <= showY + pullSearchHeight * 0.5) {
|
|
80
|
+
deliberatelyOpened.current = true
|
|
81
|
+
if (y <= showY && !refreshing && onRefresh) {
|
|
82
|
+
setRefreshing(true)
|
|
83
|
+
Promise.resolve(onRefresh()).finally(() => setRefreshing(false))
|
|
84
|
+
}
|
|
85
|
+
scrollTo(showY, true)
|
|
86
|
+
} else if (y < hideY) {
|
|
87
|
+
scrollTo(hideY, true)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
externalScrollEndDrag?.(e)
|
|
91
|
+
},
|
|
92
|
+
[externalScrollEndDrag, headerHeight, onRefresh, pullSearchHeight, refreshing, scrollTo]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const handleMomentumScrollEnd = useCallback(
|
|
96
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
97
|
+
if (pullSearchHeight && headerHeight && !deliberatelyOpened.current && !refreshing) {
|
|
98
|
+
const y = e.nativeEvent.contentOffset.y
|
|
99
|
+
const hideY = -headerHeight + pullSearchHeight
|
|
100
|
+
if (y < hideY - 1) scrollTo(hideY, true)
|
|
101
|
+
}
|
|
102
|
+
deliberatelyOpened.current = false
|
|
103
|
+
externalMomentumScrollEnd?.(e)
|
|
104
|
+
},
|
|
105
|
+
[externalMomentumScrollEnd, headerHeight, pullSearchHeight, refreshing, scrollTo]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const activeListHeader = hasPullSearch && phase === 'measuring' ? null : listHeaderComponent
|
|
109
|
+
const hiddenHeader = phase === 'measuring' && listHeaderComponent != null ? (typeof listHeaderComponent === 'function' ? createElement(listHeaderComponent as ComponentType<object>) : (listHeaderComponent as ReactElement)) : null
|
|
110
|
+
|
|
111
|
+
return { activeListHeader, handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag, hiddenHeader, pullSearchMinHeight: pullSearchHeight ?? 0 }
|
|
112
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
4
|
+
|
|
5
|
+
export const useScrollView = () => {
|
|
6
|
+
const { progress, progressing, scrollHeight, scrollPosition, setProgress, setProgressing } = useContext(ScrollViewContext)
|
|
7
|
+
return { progress, progressing, scrollHeight, scrollPosition, setProgress, setProgressing }
|
|
8
|
+
}
|