@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.
Files changed (46) hide show
  1. package/README.md +319 -0
  2. package/dist/index.d.mts +210 -0
  3. package/dist/index.d.ts +210 -0
  4. package/dist/index.js +2760 -0
  5. package/dist/index.mjs +2709 -0
  6. package/package.json +105 -0
  7. package/src/CustomList.tsx +127 -0
  8. package/src/FlatList.tsx +139 -0
  9. package/src/PullSearch.tsx +99 -0
  10. package/src/ScrollView.tsx +89 -0
  11. package/src/ScrollViewContext.ts +50 -0
  12. package/src/ScrollViewFooter.tsx +48 -0
  13. package/src/ScrollViewHeader.tsx +159 -0
  14. package/src/ScrollViewProvider.tsx +62 -0
  15. package/src/ScrollViewSettingsContext.ts +27 -0
  16. package/src/ScrollViewSettingsProvider.tsx +31 -0
  17. package/src/SectionList.tsx +195 -0
  18. package/src/__mocks__/auto-paper.ts +8 -0
  19. package/src/__mocks__/flash-list.ts +20 -0
  20. package/src/__mocks__/react-native-gesture-handler.ts +13 -0
  21. package/src/__mocks__/react-native-keyboard-controller.ts +11 -0
  22. package/src/__mocks__/react-native-paper.ts +22 -0
  23. package/src/__mocks__/react-native-reanimated.ts +30 -0
  24. package/src/__mocks__/react-native-safe-area-context.ts +2 -0
  25. package/src/__mocks__/react-native.ts +30 -0
  26. package/src/__tests__/CustomList.test.tsx +61 -0
  27. package/src/__tests__/FlatList.test.tsx +52 -0
  28. package/src/__tests__/PullSearch.test.tsx +38 -0
  29. package/src/__tests__/ScrollView.test.tsx +23 -0
  30. package/src/__tests__/ScrollViewHeader.test.tsx +28 -0
  31. package/src/__tests__/ScrollViewProvider.test.tsx +65 -0
  32. package/src/__tests__/SectionList.test.tsx +88 -0
  33. package/src/index.ts +26 -0
  34. package/src/internal/FAB.tsx +6 -0
  35. package/src/internal/HorizontalDots.tsx +99 -0
  36. package/src/internal/RefreshControl.tsx +5 -0
  37. package/src/internal/ScrollViewChip.tsx +22 -0
  38. package/src/internal/useScrollHandler.ts +101 -0
  39. package/src/internal/useScrollList.ts +64 -0
  40. package/src/internal/useStickyHeaders.ts +149 -0
  41. package/src/out.txt +61 -0
  42. package/src/redux/scrollViewSlice.ts +14 -0
  43. package/src/useKeyboardInset.ts +21 -0
  44. package/src/useScrollInit.ts +112 -0
  45. package/src/useScrollView.ts +8 -0
  46. 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
@@ -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
+ })