@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
package/package.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rific/scroll-view",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Blur styled scroll system for React Native. Floating headers/footers, keyboard awareness, and scroll-away chrome",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react-native",
|
|
7
|
+
"expo",
|
|
8
|
+
"scroll-view",
|
|
9
|
+
"blur",
|
|
10
|
+
"header",
|
|
11
|
+
"footer",
|
|
12
|
+
"keyboard-aware",
|
|
13
|
+
"reanimated",
|
|
14
|
+
"gesture-handler"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/jayrdeaton/react-native-scroll-view#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/jayrdeaton/react-native-scroll-view/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/jayrdeaton/react-native-scroll-view.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Jay Deaton",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"type": "commonjs",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"react-native": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.mjs",
|
|
33
|
+
"require": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"main": "dist/index.js",
|
|
37
|
+
"module": "dist/index.mjs",
|
|
38
|
+
"react-native": "src/index.ts",
|
|
39
|
+
"types": "dist/index.d.ts",
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"src"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
46
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --clean --watch",
|
|
47
|
+
"fix": "eslint --fix",
|
|
48
|
+
"lint": "eslint",
|
|
49
|
+
"prepublishOnly": "npm run build",
|
|
50
|
+
"push": "yalc publish --push",
|
|
51
|
+
"release": "git push --follow-tags",
|
|
52
|
+
"release:major": "npm version major && git push --follow-tags",
|
|
53
|
+
"release:minor": "npm version minor && git push --follow-tags",
|
|
54
|
+
"release:patch": "npm version patch && git push --follow-tags",
|
|
55
|
+
"test": "jest",
|
|
56
|
+
"test:watch": "jest --watchAll",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"preversion": "npm run lint && npm test && npm run build"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@reduxjs/toolkit": "^2.12.0",
|
|
62
|
+
"@rific/auto-paper": "^0.3.1",
|
|
63
|
+
"@testing-library/dom": "^10.4.1",
|
|
64
|
+
"@testing-library/react": "^16.3.2",
|
|
65
|
+
"@types/jest": "^30.0.0",
|
|
66
|
+
"@types/react": "^19.0.0",
|
|
67
|
+
"@types/react-native": "^0.73.0",
|
|
68
|
+
"@typescript-eslint/parser": "^8.59.3",
|
|
69
|
+
"eslint": "^9.39.4",
|
|
70
|
+
"eslint-config-prettier": "^10.1.8",
|
|
71
|
+
"eslint-plugin-package-json": "^1.0.0",
|
|
72
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
73
|
+
"eslint-plugin-react-native": "^5.0.0",
|
|
74
|
+
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
75
|
+
"jest": "^30.4.2",
|
|
76
|
+
"jest-environment-jsdom": "^30.4.1",
|
|
77
|
+
"prettier": "^3.8.3",
|
|
78
|
+
"react": "^19.0.0",
|
|
79
|
+
"react-dom": "^19.0.0",
|
|
80
|
+
"react-native": "^0.76.0",
|
|
81
|
+
"react-native-gesture-handler": "*",
|
|
82
|
+
"react-native-keyboard-controller": "*",
|
|
83
|
+
"react-native-paper": "^5.12.0",
|
|
84
|
+
"react-native-reanimated": "*",
|
|
85
|
+
"react-native-safe-area-context": "*",
|
|
86
|
+
"ts-jest": "^29.4.9",
|
|
87
|
+
"ts-node": "^10.9.2",
|
|
88
|
+
"tsup": "^8.0.0",
|
|
89
|
+
"typescript": "^5.0.0",
|
|
90
|
+
"typescript-eslint": "^8.59.3"
|
|
91
|
+
},
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"@rific/auto-paper": "^0.3.1",
|
|
94
|
+
"react": ">=18.0.0",
|
|
95
|
+
"react-native": ">=0.73.0",
|
|
96
|
+
"react-native-gesture-handler": "*",
|
|
97
|
+
"react-native-keyboard-controller": "*",
|
|
98
|
+
"react-native-paper": ">=5.0.0",
|
|
99
|
+
"react-native-reanimated": ">=3.0.0",
|
|
100
|
+
"react-native-safe-area-context": "*"
|
|
101
|
+
},
|
|
102
|
+
"publishConfig": {
|
|
103
|
+
"access": "public"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
|
+
|
|
5
|
+
import { RefreshControl } from './internal/RefreshControl'
|
|
6
|
+
import { ScrollViewChip } from './internal/ScrollViewChip'
|
|
7
|
+
import { useScrollHandler } from './internal/useScrollHandler'
|
|
8
|
+
import { useScrollList } from './internal/useScrollList'
|
|
9
|
+
import { useScrollInit } from './useScrollInit'
|
|
10
|
+
|
|
11
|
+
type ScrollToOffsetRef = { scrollToOffset: (params: { animated?: boolean; offset: number }) => void }
|
|
12
|
+
|
|
13
|
+
export type CustomListProps<P extends object> = Omit<P, 'contentInset' | 'contentOffset' | 'onScroll' | 'refreshControl' | 'scrollEventThrottle'> & {
|
|
14
|
+
component: ComponentType<P>
|
|
15
|
+
renderFilters?: ReactNode
|
|
16
|
+
footerFixed?: boolean
|
|
17
|
+
gesture?: GestureType
|
|
18
|
+
headerFixed?: boolean
|
|
19
|
+
keyboardAware?: boolean
|
|
20
|
+
onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
21
|
+
onRefresh?: () => Promise<void> | void
|
|
22
|
+
onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
23
|
+
onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
24
|
+
pullSearchHeight?: number
|
|
25
|
+
scrollRef?: RefObject<ScrollToOffsetRef | null>
|
|
26
|
+
style?: StyleProp<ViewStyle>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CustomListInner = <P extends object>({ component: List, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, scrollRef, style, ...props }: CustomListProps<P>) => {
|
|
30
|
+
// Intercept ListHeaderComponent so useScrollInit can apply the same 2-phase measurement as FlatList.
|
|
31
|
+
const { ListHeaderComponent: listHeaderComponent, contentContainerStyle: externalContentContainerStyle, ...restProps } = props as Record<string, unknown>
|
|
32
|
+
|
|
33
|
+
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
|
|
34
|
+
|
|
35
|
+
const scrollViewInternal = useRef<ScrollToOffsetRef | null>(null)
|
|
36
|
+
|
|
37
|
+
const handleRef = useCallback(
|
|
38
|
+
(el: ScrollToOffsetRef | null) => {
|
|
39
|
+
scrollViewInternal.current = el
|
|
40
|
+
if (scrollRef) scrollRef.current = el
|
|
41
|
+
},
|
|
42
|
+
[scrollRef]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
46
|
+
scrollViewInternal.current?.scrollToOffset({ offset, animated })
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
const {
|
|
50
|
+
activeListHeader: pullSearchHeader,
|
|
51
|
+
handleMomentumScrollEnd,
|
|
52
|
+
handleScrollBeginDrag,
|
|
53
|
+
handleScrollEndDrag,
|
|
54
|
+
hiddenHeader,
|
|
55
|
+
pullSearchMinHeight
|
|
56
|
+
} = useScrollInit({
|
|
57
|
+
listHeaderComponent: listHeaderComponent as ComponentType<object> | ReactElement | null | undefined,
|
|
58
|
+
onMomentumScrollEnd: externalMomentumScrollEnd,
|
|
59
|
+
onRefresh: pullSearchHeight ? onRefresh : undefined,
|
|
60
|
+
onScrollBeginDrag: externalScrollBeginDrag,
|
|
61
|
+
onScrollEndDrag: externalScrollEndDrag,
|
|
62
|
+
pullSearchHeight,
|
|
63
|
+
scrollTo
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const activeListHeader = useMemo(() => {
|
|
67
|
+
if (!renderFilters) return pullSearchHeader
|
|
68
|
+
const Inner = pullSearchHeader
|
|
69
|
+
return () => (
|
|
70
|
+
<>
|
|
71
|
+
{Inner ? typeof Inner === 'function' ? <Inner /> : Inner : null}
|
|
72
|
+
{renderFilters}
|
|
73
|
+
</>
|
|
74
|
+
)
|
|
75
|
+
}, [pullSearchHeader, renderFilters])
|
|
76
|
+
|
|
77
|
+
const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
78
|
+
|
|
79
|
+
const onPullSearchZoneEnter = useCallback(() => {
|
|
80
|
+
if (!pullSearchHeight) return
|
|
81
|
+
scrollViewInternal.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
|
|
82
|
+
}, [contentInset.top, pullSearchHeight])
|
|
83
|
+
|
|
84
|
+
const onScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
85
|
+
|
|
86
|
+
const handleScrollToTop = useCallback(() => {
|
|
87
|
+
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
|
88
|
+
scrollViewInternal.current?.scrollToOffset({ offset, animated: true })
|
|
89
|
+
}, [contentInset.top, pullSearchHeight])
|
|
90
|
+
|
|
91
|
+
const refreshControl = useMemo(() => <RefreshControl />, [])
|
|
92
|
+
const nativeGesture = useMemo(() => Gesture.Native(), [])
|
|
93
|
+
const combinedGesture = useMemo(() => (gesture !== undefined ? Gesture.Simultaneous(gesture, nativeGesture) : nativeGesture), [gesture, nativeGesture])
|
|
94
|
+
const detectorGesture = gesture !== undefined ? combinedGesture : undefined
|
|
95
|
+
|
|
96
|
+
const TypedList = List as ComponentType<{
|
|
97
|
+
contentContainerStyle?: unknown
|
|
98
|
+
contentInset: typeof contentInset
|
|
99
|
+
contentOffset: typeof contentOffset
|
|
100
|
+
ListHeaderComponent?: unknown
|
|
101
|
+
onMomentumScrollEnd: typeof handleMomentumScrollEnd
|
|
102
|
+
onScroll: typeof onScroll
|
|
103
|
+
onScrollBeginDrag: typeof handleScrollBeginDrag
|
|
104
|
+
onScrollEndDrag: typeof handleScrollEndDrag
|
|
105
|
+
ref?: ((el: ScrollToOffsetRef | null) => void) | RefObject<ScrollToOffsetRef | null>
|
|
106
|
+
refreshControl: ReactElement
|
|
107
|
+
scrollEventThrottle: 16
|
|
108
|
+
[key: string]: unknown
|
|
109
|
+
}>
|
|
110
|
+
|
|
111
|
+
const content = (
|
|
112
|
+
<View style={containerStyle}>
|
|
113
|
+
<TypedList {...restProps} contentContainerStyle={contentContainerStyle} contentInset={contentInset} contentOffset={contentOffset} ListHeaderComponent={activeListHeader} onMomentumScrollEnd={handleMomentumScrollEnd} onScroll={onScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} ref={handleRef} refreshControl={refreshControl} scrollEventThrottle={16} />
|
|
114
|
+
{hiddenHeader && (
|
|
115
|
+
<View pointerEvents='none' style={styles.measureContainer}>
|
|
116
|
+
{hiddenHeader}
|
|
117
|
+
</View>
|
|
118
|
+
)}
|
|
119
|
+
<ScrollViewChip onPress={handleScrollToTop} style={chipStyle} />
|
|
120
|
+
</View>
|
|
121
|
+
)
|
|
122
|
+
return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 } }
|
|
126
|
+
|
|
127
|
+
export const CustomList = memo(CustomListInner) as typeof CustomListInner
|
package/src/FlatList.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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'
|
|
3
|
+
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
|
+
import Animated from 'react-native-reanimated'
|
|
5
|
+
|
|
6
|
+
import { HorizontalDots } from './internal/HorizontalDots'
|
|
7
|
+
import { RefreshControl } from './internal/RefreshControl'
|
|
8
|
+
import { ScrollViewChip } from './internal/ScrollViewChip'
|
|
9
|
+
import { useScrollHandler } from './internal/useScrollHandler'
|
|
10
|
+
import { useScrollList } from './internal/useScrollList'
|
|
11
|
+
import { useScrollInit } from './useScrollInit'
|
|
12
|
+
|
|
13
|
+
export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshing'> & {
|
|
14
|
+
renderFilters?: ReactNode
|
|
15
|
+
footerFixed?: boolean
|
|
16
|
+
gesture?: GestureType
|
|
17
|
+
headerFixed?: boolean
|
|
18
|
+
keyboardAware?: boolean
|
|
19
|
+
onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
20
|
+
onRefresh?: () => Promise<void> | void
|
|
21
|
+
onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
22
|
+
onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
23
|
+
pullSearchHeight?: number
|
|
24
|
+
ref?: RefObject<RNFlatList | null>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FlatListInner = <T,>({ contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
|
|
28
|
+
const scrollView = useRef<RNFlatList>(null)
|
|
29
|
+
const isHorizontal = horizontal === true
|
|
30
|
+
const showDots = isHorizontal && pagingEnabled === true && (data?.length ?? 0) > 1
|
|
31
|
+
|
|
32
|
+
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
|
|
33
|
+
|
|
34
|
+
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
35
|
+
scrollView.current?.scrollToOffset({ offset, animated })
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
activeListHeader: pullSearchHeader,
|
|
40
|
+
handleMomentumScrollEnd,
|
|
41
|
+
handleScrollBeginDrag,
|
|
42
|
+
handleScrollEndDrag,
|
|
43
|
+
hiddenHeader,
|
|
44
|
+
pullSearchMinHeight
|
|
45
|
+
} = useScrollInit({
|
|
46
|
+
listHeaderComponent: externalListHeaderComponent,
|
|
47
|
+
onMomentumScrollEnd: externalMomentumScrollEnd,
|
|
48
|
+
onRefresh: pullSearchHeight ? onRefresh : undefined,
|
|
49
|
+
onScrollBeginDrag: externalScrollBeginDrag,
|
|
50
|
+
onScrollEndDrag: externalScrollEndDrag,
|
|
51
|
+
pullSearchHeight,
|
|
52
|
+
scrollTo
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const activeListHeader = useMemo(() => {
|
|
56
|
+
if (!renderFilters) return pullSearchHeader
|
|
57
|
+
const Inner = pullSearchHeader
|
|
58
|
+
return () => (
|
|
59
|
+
<>
|
|
60
|
+
{Inner ? typeof Inner === 'function' ? <Inner /> : Inner : null}
|
|
61
|
+
{renderFilters}
|
|
62
|
+
</>
|
|
63
|
+
)
|
|
64
|
+
}, [pullSearchHeader, renderFilters])
|
|
65
|
+
|
|
66
|
+
const contentContainerStyle = useMemo(() => (isHorizontal ? externalContentContainerStyle : [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle]), [isHorizontal, contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
67
|
+
|
|
68
|
+
const onPullSearchZoneEnter = useCallback(() => {
|
|
69
|
+
if (!pullSearchHeight) return
|
|
70
|
+
scrollView.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
|
|
71
|
+
}, [contentInset.top, pullSearchHeight])
|
|
72
|
+
|
|
73
|
+
const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
74
|
+
|
|
75
|
+
const handleScrollToTop = useCallback(() => {
|
|
76
|
+
if (isHorizontal) {
|
|
77
|
+
scrollView.current?.scrollToOffset({ offset: 0, animated: true })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
|
81
|
+
scrollView.current?.scrollToOffset({ offset, animated: true })
|
|
82
|
+
}, [isHorizontal, contentInset.top, pullSearchHeight])
|
|
83
|
+
|
|
84
|
+
const handleContentSizeChange = useCallback(
|
|
85
|
+
(w: number, h: number) => {
|
|
86
|
+
if (onContentSizeChange) onContentSizeChange(w, h)
|
|
87
|
+
},
|
|
88
|
+
[onContentSizeChange]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const refreshControl = useMemo(() => <RefreshControl />, [])
|
|
92
|
+
const nativeGesture = useMemo(() => Gesture.Native(), [])
|
|
93
|
+
const combinedGesture = useMemo(() => (gesture !== undefined ? Gesture.Simultaneous(gesture, nativeGesture) : nativeGesture), [gesture, nativeGesture])
|
|
94
|
+
const detectorGesture = gesture !== undefined ? combinedGesture : undefined
|
|
95
|
+
|
|
96
|
+
const content = (
|
|
97
|
+
<View style={containerStyle}>
|
|
98
|
+
<Animated.FlatList
|
|
99
|
+
{...(props as Omit<RNFlatListProps<T>, 'CellRendererComponent'>)}
|
|
100
|
+
contentContainerStyle={contentContainerStyle}
|
|
101
|
+
contentInset={contentInset}
|
|
102
|
+
contentOffset={contentOffset}
|
|
103
|
+
data={data}
|
|
104
|
+
horizontal={isHorizontal}
|
|
105
|
+
initialNumToRender={initialNumToRender}
|
|
106
|
+
ListHeaderComponent={activeListHeader}
|
|
107
|
+
maxToRenderPerBatch={maxToRenderPerBatch}
|
|
108
|
+
onContentSizeChange={handleContentSizeChange}
|
|
109
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
110
|
+
onScroll={handleScroll}
|
|
111
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
112
|
+
onScrollEndDrag={handleScrollEndDrag}
|
|
113
|
+
pagingEnabled={pagingEnabled}
|
|
114
|
+
ref={(el: RNFlatList | null) => {
|
|
115
|
+
if (externalRef) externalRef.current = el
|
|
116
|
+
scrollView.current = el
|
|
117
|
+
}}
|
|
118
|
+
refreshControl={refreshControl}
|
|
119
|
+
removeClippedSubviews={removeClippedSubviews}
|
|
120
|
+
scrollEventThrottle={16}
|
|
121
|
+
showsHorizontalScrollIndicator={showsHorizontalScrollIndicator ?? isHorizontal}
|
|
122
|
+
showsVerticalScrollIndicator={showsVerticalScrollIndicator ?? !isHorizontal}
|
|
123
|
+
windowSize={windowSize}
|
|
124
|
+
/>
|
|
125
|
+
{hiddenHeader && (
|
|
126
|
+
<View pointerEvents='none' style={styles.measureContainer}>
|
|
127
|
+
{hiddenHeader}
|
|
128
|
+
</View>
|
|
129
|
+
)}
|
|
130
|
+
<ScrollViewChip isHorizontal={isHorizontal} onPress={handleScrollToTop} style={chipStyle} />
|
|
131
|
+
{showDots && <HorizontalDots total={data?.length ?? 0} />}
|
|
132
|
+
</View>
|
|
133
|
+
)
|
|
134
|
+
return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 } }
|
|
138
|
+
|
|
139
|
+
export const FlatList = memo(FlatListInner) as typeof FlatListInner
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
|
2
|
+
import { type LayoutChangeEvent, StyleSheet, type TextInput } from 'react-native'
|
|
3
|
+
import { Searchbar, useTheme } from 'react-native-paper'
|
|
4
|
+
import Animated, { Extrapolation, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle } from 'react-native-reanimated'
|
|
5
|
+
|
|
6
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
7
|
+
|
|
8
|
+
export type PullSearchHandle = {
|
|
9
|
+
blur: () => void
|
|
10
|
+
focus: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PullSearchProps = {
|
|
14
|
+
debounce?: boolean
|
|
15
|
+
onChangeText: (text: string) => void
|
|
16
|
+
onHeightChange: (height: number) => void
|
|
17
|
+
placeholder?: string
|
|
18
|
+
value?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function PullSearch({ debounce, onChangeText, onHeightChange, placeholder, value: propValue }, ref) {
|
|
22
|
+
const { headerHeight, scrollPosition } = useContext(ScrollViewContext)
|
|
23
|
+
const theme = useTheme()
|
|
24
|
+
const inputRef = useRef<TextInput>(null)
|
|
25
|
+
const [barHeight, setBarHeight] = useState(0)
|
|
26
|
+
const [value, setValue] = useState(propValue ?? '')
|
|
27
|
+
const prevPropValueRef = useRef<string | undefined>(propValue)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (prevPropValueRef.current === propValue) return
|
|
31
|
+
prevPropValueRef.current = propValue
|
|
32
|
+
setValue(propValue ?? '')
|
|
33
|
+
}, [propValue])
|
|
34
|
+
|
|
35
|
+
useImperativeHandle(ref, () => ({
|
|
36
|
+
blur: () => inputRef.current?.blur(),
|
|
37
|
+
focus: () => inputRef.current?.focus()
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
const blurInput = useCallback(() => inputRef.current?.blur(), [])
|
|
41
|
+
|
|
42
|
+
useAnimatedReaction(
|
|
43
|
+
() => {
|
|
44
|
+
if (barHeight === 0) return false
|
|
45
|
+
return scrollPosition.value >= -headerHeight + barHeight
|
|
46
|
+
},
|
|
47
|
+
(isHidden, wasHidden) => {
|
|
48
|
+
if (isHidden && !wasHidden) runOnJS(blurInput)()
|
|
49
|
+
},
|
|
50
|
+
[barHeight, headerHeight, blurInput]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const handleClear = useCallback(() => {
|
|
54
|
+
setValue('')
|
|
55
|
+
onChangeText('')
|
|
56
|
+
}, [onChangeText])
|
|
57
|
+
|
|
58
|
+
const handleLayout = useCallback(
|
|
59
|
+
({
|
|
60
|
+
nativeEvent: {
|
|
61
|
+
layout: { height }
|
|
62
|
+
}
|
|
63
|
+
}: LayoutChangeEvent) => {
|
|
64
|
+
setBarHeight(height)
|
|
65
|
+
onHeightChange(height)
|
|
66
|
+
},
|
|
67
|
+
[onHeightChange]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const normalizedValue = value.trim()
|
|
72
|
+
if (debounce) {
|
|
73
|
+
const timeout = setTimeout(() => onChangeText(normalizedValue), 500)
|
|
74
|
+
return () => clearTimeout(timeout)
|
|
75
|
+
}
|
|
76
|
+
onChangeText(normalizedValue)
|
|
77
|
+
}, [debounce, onChangeText, value])
|
|
78
|
+
|
|
79
|
+
// Fade out as the bar scrolls up into the header blur region.
|
|
80
|
+
// showY = -headerHeight: natural rest, bar fully visible just below header.
|
|
81
|
+
// hideY = -headerHeight + barHeight: bar scrolled behind header, opacity 0.
|
|
82
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
83
|
+
if (barHeight === 0) return { opacity: 0 }
|
|
84
|
+
const showY = -headerHeight
|
|
85
|
+
const hideY = -headerHeight + barHeight
|
|
86
|
+
return { opacity: interpolate(scrollPosition.value, [showY, hideY], [1, 0], Extrapolation.CLAMP) }
|
|
87
|
+
}, [barHeight, headerHeight])
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Animated.View onLayout={handleLayout} style={[styles.container, animatedStyle]}>
|
|
91
|
+
<Searchbar autoCorrect={false} onChangeText={(text) => setValue((text || '').trimStart())} onClearIconPress={handleClear} placeholder={placeholder} placeholderTextColor={theme.colors.onSurfaceVariant} ref={inputRef} spellCheck={false} style={styles.input} value={value} />
|
|
92
|
+
</Animated.View>
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
container: { paddingVertical: 8 },
|
|
98
|
+
input: { margin: 0 }
|
|
99
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { memo, type RefObject, useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { type NativeScrollEvent, type NativeSyntheticEvent, ScrollView as RNScrollView, type ScrollViewProps as RNScrollViewProps, View } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
|
+
import Animated from 'react-native-reanimated'
|
|
5
|
+
|
|
6
|
+
import { RefreshControl } from './internal/RefreshControl'
|
|
7
|
+
import { ScrollViewChip } from './internal/ScrollViewChip'
|
|
8
|
+
import { useScrollHandler } from './internal/useScrollHandler'
|
|
9
|
+
import { useScrollList } from './internal/useScrollList'
|
|
10
|
+
import { useScrollInit } from './useScrollInit'
|
|
11
|
+
|
|
12
|
+
export type ScrollViewProps = RNScrollViewProps & {
|
|
13
|
+
footerFixed?: boolean
|
|
14
|
+
gesture?: GestureType
|
|
15
|
+
headerFixed?: boolean
|
|
16
|
+
keyboardAware?: boolean
|
|
17
|
+
onRefresh?: () => void
|
|
18
|
+
onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
19
|
+
onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
20
|
+
pullSearchHeight?: number
|
|
21
|
+
ref?: RefObject<RNScrollView | null>
|
|
22
|
+
refreshing?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ScrollViewInner = ({ children, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, style, ...props }: ScrollViewProps) => {
|
|
26
|
+
const scrollView = useRef<RNScrollView>(null)
|
|
27
|
+
const isHorizontal = horizontal === true
|
|
28
|
+
|
|
29
|
+
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
|
|
30
|
+
|
|
31
|
+
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
32
|
+
scrollView.current?.scrollTo({ y: offset, animated })
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const { handleMomentumScrollEnd, handleScrollBeginDrag, handleScrollEndDrag } = useScrollInit({
|
|
36
|
+
onMomentumScrollEnd: externalMomentumScrollEnd,
|
|
37
|
+
onScrollBeginDrag: externalScrollBeginDrag,
|
|
38
|
+
onScrollEndDrag: externalScrollEndDrag,
|
|
39
|
+
pullSearchHeight,
|
|
40
|
+
scrollTo
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const onPullSearchZoneEnter = useCallback(() => {
|
|
44
|
+
if (!pullSearchHeight) return
|
|
45
|
+
scrollTo(-contentInset.top + pullSearchHeight, true)
|
|
46
|
+
}, [contentInset.top, pullSearchHeight, scrollTo])
|
|
47
|
+
|
|
48
|
+
const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
49
|
+
|
|
50
|
+
const handleScrollToTop = useCallback(() => {
|
|
51
|
+
if (isHorizontal) {
|
|
52
|
+
scrollView.current?.scrollTo({ x: 0, animated: true })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
|
56
|
+
scrollView.current?.scrollTo({ y: offset, animated: true })
|
|
57
|
+
}, [isHorizontal, contentInset.top, pullSearchHeight])
|
|
58
|
+
|
|
59
|
+
const refreshControl = useMemo(() => (onRefresh ? <RefreshControl onRefresh={onRefresh} refreshing={refreshing ?? false} /> : <RefreshControl />), [onRefresh, refreshing])
|
|
60
|
+
const nativeGesture = useMemo(() => Gesture.Native(), [])
|
|
61
|
+
const combinedGesture = useMemo(() => (gesture !== undefined ? Gesture.Simultaneous(gesture, nativeGesture) : nativeGesture), [gesture, nativeGesture])
|
|
62
|
+
const detectorGesture = gesture !== undefined ? combinedGesture : undefined
|
|
63
|
+
|
|
64
|
+
const content = (
|
|
65
|
+
<View style={containerStyle}>
|
|
66
|
+
<Animated.ScrollView
|
|
67
|
+
{...props}
|
|
68
|
+
contentInset={contentInset}
|
|
69
|
+
contentOffset={contentOffset}
|
|
70
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
71
|
+
onScroll={handleScroll}
|
|
72
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
73
|
+
onScrollEndDrag={handleScrollEndDrag}
|
|
74
|
+
ref={(el: RNScrollView | null) => {
|
|
75
|
+
if (props.ref) props.ref.current = el
|
|
76
|
+
scrollView.current = el
|
|
77
|
+
}}
|
|
78
|
+
refreshControl={refreshControl}
|
|
79
|
+
scrollEventThrottle={16}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</Animated.ScrollView>
|
|
83
|
+
<ScrollViewChip isHorizontal={isHorizontal} onPress={handleScrollToTop} style={chipStyle} />
|
|
84
|
+
</View>
|
|
85
|
+
)
|
|
86
|
+
return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const ScrollView = memo(ScrollViewInner) as typeof ScrollViewInner
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
import { SharedValue } from 'react-native-reanimated'
|
|
3
|
+
|
|
4
|
+
export type ScrollViewContextType = {
|
|
5
|
+
blur: boolean
|
|
6
|
+
footerHeight: number
|
|
7
|
+
footerHeightShared: SharedValue<number>
|
|
8
|
+
footerFixed: boolean
|
|
9
|
+
footerOffset: SharedValue<number>
|
|
10
|
+
headerHeight: number
|
|
11
|
+
headerHeightShared: SharedValue<number>
|
|
12
|
+
headerFixed: boolean
|
|
13
|
+
headerOffset: SharedValue<number>
|
|
14
|
+
progress: number | null
|
|
15
|
+
pullSearchHeightShared: SharedValue<number>
|
|
16
|
+
snapBackFooterShared: SharedValue<boolean>
|
|
17
|
+
snapBackHeaderShared: SharedValue<boolean>
|
|
18
|
+
progressing: boolean
|
|
19
|
+
scrollHeight: number
|
|
20
|
+
scrollPosition: SharedValue<number>
|
|
21
|
+
setFooterHeight: (h: number) => void
|
|
22
|
+
setHeaderHeight: (h: number) => void
|
|
23
|
+
setProgress: (p: number | null) => void
|
|
24
|
+
setProgressing: (b: boolean) => void
|
|
25
|
+
tabBarHeight: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ScrollViewContext = createContext<ScrollViewContextType>({
|
|
29
|
+
blur: true,
|
|
30
|
+
footerHeight: 0,
|
|
31
|
+
footerHeightShared: null as unknown as SharedValue<number>,
|
|
32
|
+
footerFixed: false,
|
|
33
|
+
footerOffset: null as unknown as SharedValue<number>,
|
|
34
|
+
headerHeight: 0,
|
|
35
|
+
headerHeightShared: null as unknown as SharedValue<number>,
|
|
36
|
+
headerFixed: false,
|
|
37
|
+
headerOffset: null as unknown as SharedValue<number>,
|
|
38
|
+
progress: null,
|
|
39
|
+
progressing: false,
|
|
40
|
+
pullSearchHeightShared: null as unknown as SharedValue<number>,
|
|
41
|
+
scrollHeight: 0,
|
|
42
|
+
scrollPosition: null as unknown as SharedValue<number>,
|
|
43
|
+
setFooterHeight: () => {},
|
|
44
|
+
setHeaderHeight: () => {},
|
|
45
|
+
setProgress: () => {},
|
|
46
|
+
setProgressing: () => {},
|
|
47
|
+
snapBackFooterShared: null as unknown as SharedValue<boolean>,
|
|
48
|
+
snapBackHeaderShared: null as unknown as SharedValue<boolean>,
|
|
49
|
+
tabBarHeight: 60
|
|
50
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BlurView } from '@rific/auto-paper'
|
|
2
|
+
import { type ReactNode, useContext, useEffect } from 'react'
|
|
3
|
+
import { type LayoutChangeEvent, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
4
|
+
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
|
5
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
6
|
+
|
|
7
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
8
|
+
|
|
9
|
+
export type ScrollViewFooterProps = {
|
|
10
|
+
children?: ReactNode | ReactNode[]
|
|
11
|
+
style?: ViewStyle
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) => {
|
|
15
|
+
const { blur, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared } = useContext(ScrollViewContext)
|
|
16
|
+
const insets = useSafeAreaInsets()
|
|
17
|
+
useEffect(
|
|
18
|
+
() => () => {
|
|
19
|
+
setFooterHeight(0)
|
|
20
|
+
},
|
|
21
|
+
[setFooterHeight]
|
|
22
|
+
)
|
|
23
|
+
const handleLayout = ({
|
|
24
|
+
nativeEvent: {
|
|
25
|
+
layout: { height }
|
|
26
|
+
}
|
|
27
|
+
}: LayoutChangeEvent) => {
|
|
28
|
+
if (footerHeight !== height) setFooterHeight(height)
|
|
29
|
+
}
|
|
30
|
+
const footerStyle = useAnimatedStyle(() => {
|
|
31
|
+
if (footerFixed) return { transform: [{ translateY: 0 }] }
|
|
32
|
+
if (snapBackFooterShared.value) return { transform: [{ translateY: footerOffset.value }] }
|
|
33
|
+
const effective = scrollPosition.value + headerHeightShared.value - pullSearchHeightShared.value
|
|
34
|
+
if (effective <= 0) return { transform: [{ translateY: 0 }] }
|
|
35
|
+
return { transform: [{ translateY: Math.min(effective, footerHeight) }] }
|
|
36
|
+
}, [footerHeight, footerFixed])
|
|
37
|
+
return (
|
|
38
|
+
<Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[styles.footer, footerStyle]}>
|
|
39
|
+
{footerHeight > 0 && <BlurView blur={blur} style={StyleSheet.absoluteFill} />}
|
|
40
|
+
<View style={[styles.row, { paddingBottom: insets.bottom }, style]}>{children}</View>
|
|
41
|
+
</Animated.View>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styles = StyleSheet.create({
|
|
46
|
+
footer: { bottom: 0, left: 0, position: 'absolute', right: 0, zIndex: 2 },
|
|
47
|
+
row: { alignItems: 'center', flexDirection: 'row', zIndex: 1 }
|
|
48
|
+
})
|