@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
@@ -0,0 +1,159 @@
1
+ import { BlurView } from '@rific/auto-paper'
2
+ import { type ReactNode, useContext } from 'react'
3
+ import { type LayoutChangeEvent, StyleSheet, Text, View, type ViewStyle } from 'react-native'
4
+ import { Appbar, ProgressBar, Surface, useTheme } from 'react-native-paper'
5
+ import Animated, { useAnimatedStyle } from 'react-native-reanimated'
6
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
7
+
8
+ import { ScrollViewContext } from './ScrollViewContext'
9
+ import { ScrollViewSettingsContext } from './ScrollViewSettingsContext'
10
+
11
+ export type ScrollViewHeaderProps = {
12
+ actionSize?: number
13
+ actionStyle?: ViewStyle
14
+ backAction?: () => void
15
+ backActionFixed?: boolean
16
+ caption?: string
17
+ centerContent?: ReactNode
18
+ children?: ReactNode | ReactNode[]
19
+ iconSize?: number
20
+ style?: ViewStyle
21
+ title?: string
22
+ topInset?: boolean
23
+ trailingAction?: ReactNode
24
+ trailingActionFixed?: boolean
25
+ }
26
+
27
+ type ActionBgProps = { blur: boolean; style?: ViewStyle }
28
+
29
+ const ActionBg = ({ blur, style }: ActionBgProps) =>
30
+ blur ? (
31
+ <View style={[StyleSheet.absoluteFill, styles.actionBg, style]}>
32
+ <BlurView blur style={StyleSheet.absoluteFill} />
33
+ </View>
34
+ ) : (
35
+ <View style={[StyleSheet.absoluteFill, styles.actionBg, style]} pointerEvents='none'>
36
+ <Surface style={StyleSheet.absoluteFill} elevation={1}>
37
+ {null}
38
+ </Surface>
39
+ </View>
40
+ )
41
+
42
+ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionFixed, caption, centerContent, children, iconSize, style, title, topInset = true, trailingAction, trailingActionFixed = true }: ScrollViewHeaderProps) => {
43
+ const { blur, headerHeight, headerFixed, headerOffset, progress, progressing, pullSearchHeightShared, scrollPosition, setHeaderHeight, snapBackHeaderShared } = useContext(ScrollViewContext)
44
+ const { settings } = useContext(ScrollViewSettingsContext)
45
+ const effectiveBackActionFixed = backActionFixed ?? settings.backActionFixed
46
+ const insets = useSafeAreaInsets()
47
+ const theme = useTheme()
48
+ const top = topInset ? insets.top : 0
49
+ const handleLayout = ({
50
+ nativeEvent: {
51
+ layout: { height }
52
+ }
53
+ }: LayoutChangeEvent) => {
54
+ if (headerHeight !== height) setHeaderHeight(height)
55
+ }
56
+ const translateStyle = useAnimatedStyle(() => {
57
+ if (headerFixed) return { transform: [{ translateY: 0 }] }
58
+ if (snapBackHeaderShared.value) return { transform: [{ translateY: headerOffset.value }] }
59
+ const effective = scrollPosition.value + headerHeight - pullSearchHeightShared.value
60
+ if (effective <= 0) return { transform: [{ translateY: 0 }] }
61
+ return { transform: [{ translateY: -effective }] }
62
+ }, [headerFixed, headerHeight])
63
+ const blurStyle = useAnimatedStyle(() => {
64
+ if (!headerHeight) return { height: 0 }
65
+ if (headerFixed) return { height: headerHeight }
66
+ const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
67
+ return { height: Math.max(headerHeight + translateY, top) }
68
+ }, [headerFixed, headerHeight, top])
69
+ const progressStyle = useAnimatedStyle(() => {
70
+ if (headerFixed) return { top: headerHeight }
71
+ const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
72
+ return { top: Math.max(headerHeight - slide, top) }
73
+ }, [headerFixed, headerHeight, top])
74
+ const actionMargin = 4
75
+ const contentMinHeight = actionSize + 2 * actionMargin
76
+ const actionTop = top + actionMargin
77
+ const buttonStyle = { borderRadius: actionSize, height: actionSize, margin: 0, width: actionSize }
78
+ const actionShadow = { borderRadius: actionSize / 2, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15, shadowRadius: 2 }
79
+ const leadingStyle = { alignItems: 'center' as const, height: actionSize, justifyContent: 'center' as const, left: actionMargin, position: 'absolute' as const, top: actionTop, width: actionSize, zIndex: 3, ...actionShadow }
80
+ const trailingStyle = { alignItems: 'center' as const, height: actionSize, justifyContent: 'center' as const, position: 'absolute' as const, right: actionMargin, top: actionTop, width: actionSize, zIndex: 3, ...actionShadow }
81
+ return (
82
+ <>
83
+ <Animated.View pointerEvents='none' style={[styles.blur, blurStyle]}>
84
+ {headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
85
+ </Animated.View>
86
+ <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === 0 ? styles.headerInit : styles.header, translateStyle]}>
87
+ <View style={[{ paddingTop: top }, style]}>
88
+ <View style={[styles.content, { minHeight: contentMinHeight }]}>
89
+ <View style={styles.side} />
90
+ <View style={styles.spacer} />
91
+ <View style={styles.side}>{children}</View>
92
+ {(centerContent || title || caption) && (
93
+ <View style={[StyleSheet.absoluteFill, styles.titleContainer]} pointerEvents={centerContent ? 'box-none' : 'none'}>
94
+ {centerContent ?? (
95
+ <>
96
+ {title && (
97
+ <Text numberOfLines={1} style={[styles.title, { color: theme.colors.onSurface }]}>
98
+ {title}
99
+ </Text>
100
+ )}
101
+ {caption && (
102
+ <Text numberOfLines={1} style={[styles.caption, { color: theme.colors.onSurfaceVariant }]}>
103
+ {caption}
104
+ </Text>
105
+ )}
106
+ </>
107
+ )}
108
+ </View>
109
+ )}
110
+ </View>
111
+ </View>
112
+ {!blur && <View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />}
113
+ </Animated.View>
114
+ <Animated.View pointerEvents='none' style={[styles.progress, progressStyle]}>
115
+ <ProgressBar indeterminate={progress === null} visible={progressing} progress={progress ?? undefined} />
116
+ </Animated.View>
117
+ {backAction &&
118
+ (effectiveBackActionFixed ? (
119
+ <Animated.View style={leadingStyle}>
120
+ <ActionBg blur={blur} style={actionStyle} />
121
+ <Appbar.BackAction onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
122
+ </Animated.View>
123
+ ) : (
124
+ <Animated.View style={[leadingStyle, translateStyle]}>
125
+ <ActionBg blur={blur} style={actionStyle} />
126
+ <Appbar.BackAction onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
127
+ </Animated.View>
128
+ ))}
129
+ {trailingAction &&
130
+ (trailingActionFixed ? (
131
+ <View style={trailingStyle}>
132
+ <ActionBg blur={blur} style={actionStyle} />
133
+ {trailingAction}
134
+ </View>
135
+ ) : (
136
+ <Animated.View style={[trailingStyle, translateStyle]}>
137
+ <ActionBg blur={blur} style={actionStyle} />
138
+ {trailingAction}
139
+ </Animated.View>
140
+ ))}
141
+ </>
142
+ )
143
+ }
144
+
145
+ const styles = StyleSheet.create({
146
+ actionBg: { borderRadius: 100, overflow: 'hidden' },
147
+ divider: { bottom: 0, height: StyleSheet.hairlineWidth, left: 0, position: 'absolute', right: 0 },
148
+ caption: { fontSize: 12, fontWeight: '400', textAlign: 'center' },
149
+ content: { alignItems: 'center', flexDirection: 'row', width: '100%' },
150
+ blur: { left: 0, overflow: 'hidden', position: 'absolute', right: 0, top: 0, zIndex: 2 },
151
+ blurInner: { left: 0, position: 'absolute', right: 0, top: 0 },
152
+ header: { left: 0, overflow: 'hidden', position: 'absolute', right: 0, top: 0, zIndex: 2 },
153
+ headerInit: { left: 0, overflow: 'hidden', right: 0, zIndex: 2 },
154
+ progress: { left: 0, position: 'absolute', right: 0, zIndex: 3 },
155
+ side: { alignItems: 'center', flexDirection: 'row' },
156
+ spacer: { flex: 1 },
157
+ title: { fontSize: 20, fontWeight: '400', textAlign: 'center' },
158
+ titleContainer: { alignItems: 'center', justifyContent: 'center' }
159
+ })
@@ -0,0 +1,62 @@
1
+ import { useBlur } from '@rific/auto-paper'
2
+ import { type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
3
+ import { Dimensions } from 'react-native'
4
+ import { useSharedValue } from 'react-native-reanimated'
5
+
6
+ import { ScrollViewContext } from './ScrollViewContext'
7
+ import { ScrollViewSettingsContext } from './ScrollViewSettingsContext'
8
+
9
+ export type ScrollViewProviderProps = {
10
+ blur?: boolean
11
+ children: ReactNode
12
+ fixed?: boolean
13
+ footerFixed?: boolean
14
+ headerFixed?: boolean
15
+ snapBack?: boolean
16
+ snapBackFooter?: boolean
17
+ snapBackHeader?: boolean
18
+ tabBarHeight?: number
19
+ }
20
+
21
+ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 60 }: ScrollViewProviderProps) => {
22
+ const { settings } = useContext(ScrollViewSettingsContext)
23
+ const effectiveBlur = useBlur(blur)
24
+ const effectiveSnapBack = snapBack ?? settings.snapBack
25
+ const effectiveSnapBackHeader = snapBackHeader ?? settings.snapBackHeader
26
+ const effectiveSnapBackFooter = snapBackFooter ?? settings.snapBackFooter
27
+ const [headerHeight, setHeaderHeightState] = useState(0)
28
+ const [footerHeight, setFooterHeightState] = useState(0)
29
+ const [progress, setProgress] = useState<number | null>(null)
30
+ const [progressing, setProgressing] = useState(false)
31
+ const scrollPosition = useSharedValue(0)
32
+ const headerHeightShared = useSharedValue(0)
33
+ const footerHeightShared = useSharedValue(0)
34
+ const headerOffset = useSharedValue(0)
35
+ const footerOffset = useSharedValue(0)
36
+ const pullSearchHeightShared = useSharedValue(0)
37
+ const snapBackHeaderShared = useSharedValue(false)
38
+ const snapBackFooterShared = useSharedValue(false)
39
+ useEffect(() => {
40
+ snapBackHeaderShared.value = effectiveSnapBackHeader ?? effectiveSnapBack
41
+ snapBackFooterShared.value = effectiveSnapBackFooter ?? effectiveSnapBack
42
+ }, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
43
+ const setHeaderHeight = useCallback(
44
+ (h: number) => {
45
+ setHeaderHeightState(h)
46
+ headerHeightShared.value = h
47
+ },
48
+ [headerHeightShared]
49
+ )
50
+ const setFooterHeight = useCallback(
51
+ (h: number) => {
52
+ setFooterHeightState(h)
53
+ footerHeightShared.value = h
54
+ },
55
+ [footerHeightShared]
56
+ )
57
+ const scrollHeight = useMemo(() => Dimensions.get('window').height - headerHeight - footerHeight, [headerHeight, footerHeight])
58
+ const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
59
+ const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
60
+ const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
61
+ return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
62
+ }
@@ -0,0 +1,27 @@
1
+ import { createContext } from 'react'
2
+
3
+ export type ScrollViewSettings = {
4
+ backActionFixed: boolean
5
+ footerFixed: boolean
6
+ headerFixed: boolean
7
+ snapBack: boolean
8
+ snapBackFooter?: boolean
9
+ snapBackHeader?: boolean
10
+ }
11
+
12
+ export const defaultScrollViewSettings: ScrollViewSettings = {
13
+ backActionFixed: true,
14
+ footerFixed: false,
15
+ headerFixed: false,
16
+ snapBack: false
17
+ }
18
+
19
+ export type ScrollViewSettingsContextType = {
20
+ settings: ScrollViewSettings
21
+ set: (patch: Partial<ScrollViewSettings>) => void
22
+ }
23
+
24
+ export const ScrollViewSettingsContext = createContext<ScrollViewSettingsContextType>({
25
+ settings: defaultScrollViewSettings,
26
+ set: () => {}
27
+ })
@@ -0,0 +1,31 @@
1
+ import { type ReactNode, useCallback, useMemo, useState } from 'react'
2
+
3
+ import { defaultScrollViewSettings, type ScrollViewSettings, ScrollViewSettingsContext } from './ScrollViewSettingsContext'
4
+
5
+ export type ScrollViewSettingsProviderProps = {
6
+ children: ReactNode
7
+ onChange?: (settings: ScrollViewSettings) => void
8
+ value?: Partial<ScrollViewSettings>
9
+ }
10
+
11
+ export const ScrollViewSettingsProvider = ({ children, onChange, value }: ScrollViewSettingsProviderProps) => {
12
+ const [settings, setSettings] = useState<ScrollViewSettings>(() => ({
13
+ ...defaultScrollViewSettings,
14
+ ...value
15
+ }))
16
+
17
+ const set = useCallback(
18
+ (patch: Partial<ScrollViewSettings>) => {
19
+ setSettings((prev) => {
20
+ const next = { ...prev, ...patch }
21
+ onChange?.(next)
22
+ return next
23
+ })
24
+ },
25
+ [onChange]
26
+ )
27
+
28
+ const ctx = useMemo(() => ({ settings, set }), [settings, set])
29
+
30
+ return <ScrollViewSettingsContext.Provider value={ctx}>{children}</ScrollViewSettingsContext.Provider>
31
+ }
@@ -0,0 +1,195 @@
1
+ import { memo, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
+ import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
3
+ import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
+ import Animated, { createAnimatedComponent } 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 { useStickyHeaders } from './internal/useStickyHeaders'
11
+ import { useScrollInit } from './useScrollInit'
12
+
13
+ const AnimatedSectionList = createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
14
+
15
+ export type SectionListProps<ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }> = Omit<RNSectionListProps<ItemT, SectionT>, 'horizontal'> & {
16
+ renderFilters?: ReactNode
17
+ footerFixed?: boolean
18
+ gesture?: GestureType
19
+ headerFixed?: boolean
20
+ keyboardAware?: boolean
21
+ onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
22
+ onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
23
+ onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
24
+ pullSearchHeight?: number
25
+ ref?: RefObject<RNSectionList<ItemT, SectionT> | null>
26
+ }
27
+
28
+ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }>({ contentContainerStyle: externalContentContainerStyle, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onContentSizeChange, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, removeClippedSubviews = false, sections, showsVerticalScrollIndicator, style, ref: externalRef, windowSize = 100, ...props }: SectionListProps<ItemT, SectionT>) => {
29
+ const scrollView = useRef<RNSectionList<ItemT, SectionT>>(null)
30
+
31
+ const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
32
+
33
+ const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
34
+
35
+ const usesCustomSticky = stickySectionHeadersEnabled && renderSectionHeader != null
36
+ const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
37
+
38
+ // useLayoutEffect (not useEffect) so the reset runs before native layout fires onLayout
39
+ // for the newly mounted section headers — otherwise the reset would clear correct positions.
40
+ useLayoutEffect(() => {
41
+ if (usesCustomSticky) resetPositions(sections.length)
42
+ }, [sections, usesCustomSticky, resetPositions, headerHeight])
43
+
44
+ // Per-section view refs so we can call view.measure() for window-coordinate Y.
45
+ const sectionViewRefs = useRef<Map<number, View | null>>(new Map())
46
+
47
+ const wrappedRenderSectionHeader = useMemo(() => {
48
+ if (!usesCustomSticky) return renderSectionHeader ?? undefined
49
+ return (info: { section: SectionT }) => {
50
+ const idx = sections.findIndex((s) => s === info.section)
51
+ return (
52
+ <View
53
+ ref={(el: View | null) => {
54
+ sectionViewRefs.current.set(idx, el)
55
+ }}
56
+ onLayout={() => {
57
+ const el = sectionViewRefs.current.get(idx)
58
+ el?.measure((_x, _y, _w, h, _px, pageY) => {
59
+ // Guard against stale callbacks from unmounted views (e.g. after key remount)
60
+ if (idx >= 0 && sectionViewRefs.current.get(idx) === el) measureHeader(idx, pageY, h)
61
+ })
62
+ }}
63
+ >
64
+ {renderSectionHeader(info)}
65
+ </View>
66
+ )
67
+ }
68
+ }, [usesCustomSticky, renderSectionHeader, sections, measureHeader])
69
+
70
+ const scrollTo = useCallback((offset: number, animated: boolean) => {
71
+ scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated })
72
+ }, [])
73
+
74
+ const {
75
+ activeListHeader: pullSearchHeader,
76
+ handleMomentumScrollEnd,
77
+ handleScrollBeginDrag,
78
+ handleScrollEndDrag,
79
+ hiddenHeader,
80
+ pullSearchMinHeight
81
+ } = useScrollInit({
82
+ listHeaderComponent: externalListHeaderComponent,
83
+ onMomentumScrollEnd: externalMomentumScrollEnd,
84
+ onScrollBeginDrag: externalScrollBeginDrag,
85
+ onScrollEndDrag: externalScrollEndDrag,
86
+ pullSearchHeight,
87
+ scrollTo
88
+ })
89
+
90
+ const activeListHeader = useMemo(() => {
91
+ if (!renderFilters) return pullSearchHeader
92
+ const Inner = pullSearchHeader
93
+ return () => (
94
+ <>
95
+ {Inner ? typeof Inner === 'function' ? <Inner /> : Inner : null}
96
+ {renderFilters}
97
+ </>
98
+ )
99
+ }, [pullSearchHeader, renderFilters])
100
+
101
+ // When PullSearch is first added to the list, onLayout doesn't re-fire for already-visible
102
+ // section headers (their frame relative to their direct parent is unchanged). Re-measure them
103
+ // once after the layout settles so positions account for the PullSearch height offset.
104
+ const pullSearchSettled = useRef(false)
105
+ useEffect(() => {
106
+ if (!usesCustomSticky || activeListHeader == null || pullSearchSettled.current) return
107
+ pullSearchSettled.current = true
108
+ const handle = requestAnimationFrame(() => {
109
+ sectionViewRefs.current.forEach((el, idx) => {
110
+ el?.measure((_x, _y, _w, h, _px, pageY) => {
111
+ if (sectionViewRefs.current.get(idx) === el) measureHeader(idx, pageY, h)
112
+ })
113
+ })
114
+ })
115
+ return () => cancelAnimationFrame(handle)
116
+ }, [activeListHeader, usesCustomSticky, measureHeader])
117
+
118
+ const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
119
+
120
+ const onPullSearchZoneEnter = useCallback(() => {
121
+ if (!pullSearchHeight) return
122
+ scrollTo(-contentInset.top + pullSearchHeight, true)
123
+ }, [contentInset.top, pullSearchHeight, scrollTo])
124
+
125
+ const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
126
+
127
+ const handleScrollToTop = useCallback(() => {
128
+ const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
129
+ scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated: true })
130
+ }, [contentInset.top, pullSearchHeight])
131
+
132
+ const handleContentSizeChange = useCallback(
133
+ (w: number, h: number) => {
134
+ if (onContentSizeChange) onContentSizeChange(w, h)
135
+ },
136
+ [onContentSizeChange]
137
+ )
138
+
139
+ const refreshControl = useMemo(() => (onRefresh ? <RefreshControl onRefresh={onRefresh} refreshing={refreshing ?? false} /> : <RefreshControl />), [onRefresh, refreshing])
140
+ const nativeGesture = useMemo(() => Gesture.Native(), [])
141
+ const combinedGesture = useMemo(() => (gesture !== undefined ? Gesture.Simultaneous(gesture, nativeGesture) : nativeGesture), [gesture, nativeGesture])
142
+ const detectorGesture = gesture !== undefined ? combinedGesture : undefined
143
+
144
+ const content = (
145
+ <View style={containerStyle}>
146
+ <AnimatedSectionList
147
+ key={headerHeight > 0 ? 1 : 0}
148
+ {...passThroughProps}
149
+ contentContainerStyle={contentContainerStyle}
150
+ contentInset={contentInset}
151
+ contentOffset={contentOffset}
152
+ initialNumToRender={initialNumToRender}
153
+ ListHeaderComponent={activeListHeader}
154
+ maxToRenderPerBatch={maxToRenderPerBatch}
155
+ onContentSizeChange={handleContentSizeChange}
156
+ onMomentumScrollEnd={handleMomentumScrollEnd}
157
+ onScroll={handleScroll}
158
+ onScrollBeginDrag={handleScrollBeginDrag}
159
+ onScrollEndDrag={handleScrollEndDrag}
160
+ ref={(el: RNSectionList<ItemT, SectionT> | null) => {
161
+ if (externalRef) externalRef.current = el
162
+ scrollView.current = el
163
+ }}
164
+ refreshControl={refreshControl}
165
+ removeClippedSubviews={removeClippedSubviews}
166
+ renderSectionHeader={wrappedRenderSectionHeader}
167
+ scrollEventThrottle={16}
168
+ sections={sections}
169
+ showsVerticalScrollIndicator={showsVerticalScrollIndicator ?? true}
170
+ stickySectionHeadersEnabled={false}
171
+ windowSize={windowSize}
172
+ />
173
+ {hiddenHeader && (
174
+ <View pointerEvents='none' style={styles.measureContainer}>
175
+ {hiddenHeader}
176
+ </View>
177
+ )}
178
+ <ScrollViewChip onPress={handleScrollToTop} style={chipStyle} />
179
+ {usesCustomSticky && (
180
+ <Animated.View pointerEvents='none' style={[styles.stickyClip, clipStyle]}>
181
+ {activeIndex >= 0 && activeIndex < sections.length && <Animated.View style={[styles.stickyOverlay, overlayStyle]}>{renderSectionHeader({ section: sections[activeIndex] })}</Animated.View>}
182
+ </Animated.View>
183
+ )}
184
+ </View>
185
+ )
186
+ return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
187
+ }
188
+
189
+ const styles = {
190
+ measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 },
191
+ stickyClip: { bottom: 0, left: 0, overflow: 'hidden' as const, position: 'absolute' as const, right: 0, zIndex: 2 },
192
+ stickyOverlay: { left: 0, position: 'absolute' as const, right: 0 }
193
+ }
194
+
195
+ export const SectionList = memo(SectionListInner) as typeof SectionListInner
@@ -0,0 +1,8 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ export const BlurView = jest.fn(stub)
6
+ export const FAB = jest.fn(stub)
7
+ export const usePaperDefaults = () => ({ FAB: {} })
8
+ export const useBlur = (blur?: boolean) => blur ?? false
@@ -0,0 +1,20 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ // Minimal type surface so ts-jest can resolve FlashListProps without the real package
6
+ export type FlashListProps<T> = {
7
+ data?: readonly T[] | null
8
+ estimatedItemSize?: number
9
+ renderItem?: ((info: { item: T; index: number }) => React.ReactElement | null) | null
10
+ contentInset?: { top?: number; bottom?: number; left?: number; right?: number }
11
+ contentOffset?: { x: number; y: number }
12
+ onRefresh?: (() => void) | null
13
+ onScroll?: unknown
14
+ refreshControl?: React.ReactElement | null
15
+ refreshing?: boolean | null
16
+ scrollEventThrottle?: number
17
+ [key: string]: unknown
18
+ }
19
+
20
+ export const FlashList = jest.fn(stub)
@@ -0,0 +1,13 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ export const GestureDetector = stub
6
+ export const useNativeGesture = jest.fn(() => ({}))
7
+ export const useSimultaneousGestures = jest.fn((...gestures: unknown[]) => gestures[0])
8
+ export const Gesture = {
9
+ Native: () => ({}),
10
+ Simultaneous: (..._gestures: unknown[]) => ({})
11
+ }
12
+ export type GestureType = object
13
+ export type SingleGesture = object
@@ -0,0 +1,11 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ export const KeyboardAwareScrollView = stub
6
+ export const KeyboardAwareFlatList = stub
7
+ export const useKeyboardHandler = jest.fn()
8
+ export const useReanimatedKeyboardAnimation = () => ({
9
+ height: { value: 0 },
10
+ progress: { value: 0 }
11
+ })
@@ -0,0 +1,22 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ export const ProgressBar = jest.fn(() => null)
6
+ export const Surface = jest.fn(stub)
7
+ export const Chip = jest.fn(stub)
8
+ export const Searchbar = jest.fn(() => null)
9
+
10
+ const BackAction = jest.fn(() => null)
11
+ export const Appbar = { BackAction }
12
+
13
+ export const useTheme = () => ({
14
+ dark: false,
15
+ colors: {
16
+ primary: '#6200ee',
17
+ surface: '#ffffff',
18
+ onSurface: '#000000',
19
+ onSurfaceVariant: '#49454f',
20
+ outlineVariant: '#cac4d0'
21
+ }
22
+ })
@@ -0,0 +1,30 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ export const useSharedValue = (init: number) => ({ value: init })
6
+ export const useAnimatedStyle = (_factory: () => object) => ({})
7
+ export const useAnimatedProps = (_factory: () => object) => ({})
8
+ export const useAnimatedScrollHandler = (_handler: unknown) => jest.fn()
9
+ export const useAnimatedReaction = jest.fn()
10
+ export const useDerivedValue = (factory: () => unknown) => ({ value: factory() })
11
+ export const withTiming = (value: number) => value
12
+ export const withSpring = (value: number) => value
13
+ export const runOnJS =
14
+ <T extends (...args: unknown[]) => unknown>(fn: T) =>
15
+ (...args: Parameters<T>) =>
16
+ fn(...args)
17
+
18
+ export const createAnimatedComponent = (C: React.ComponentType) => C
19
+
20
+ // The default export IS the Animated namespace — `import Animated from 'reanimated'` → Animated.View
21
+ const Animated = {
22
+ View: stub,
23
+ Text: stub,
24
+ ScrollView: stub,
25
+ FlatList: stub,
26
+ createAnimatedComponent
27
+ }
28
+
29
+ export { Animated }
30
+ export default Animated
@@ -0,0 +1,2 @@
1
+ export const useSafeAreaInsets = () => ({ top: 44, bottom: 34, left: 0, right: 0 })
2
+ export const SafeAreaProvider = ({ children }: { children?: React.ReactNode }) => children ?? null
@@ -0,0 +1,30 @@
1
+ import React from 'react'
2
+
3
+ const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
+
5
+ const StyleSheet = {
6
+ absoluteFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
7
+ create: <T extends object>(styles: T): T => styles,
8
+ flatten: (style: unknown) => style
9
+ }
10
+
11
+ const Dimensions = {
12
+ get: (_dim: string) => ({ width: 390, height: 844 })
13
+ }
14
+
15
+ const Platform = {
16
+ OS: 'ios' as 'ios' | 'android',
17
+ select: (obj: Record<string, unknown>) => obj[Platform.OS] ?? obj.default
18
+ }
19
+
20
+ export { Dimensions, Platform, StyleSheet }
21
+
22
+ export const View = stub
23
+ export const Text = stub
24
+ export const Pressable = stub
25
+ export const TouchableOpacity = stub
26
+ export const FlatList = jest.fn(stub)
27
+ export const RefreshControl = jest.fn(stub)
28
+ export const SectionList = jest.fn(stub)
29
+ export const ScrollView = jest.fn(stub)
30
+ export const useWindowDimensions = () => ({ width: 390, height: 844, fontScale: 1, scale: 1 })