@rific/scroll-view 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/dist/index.d.mts +210 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +2760 -0
- package/dist/index.mjs +2709 -0
- package/package.json +105 -0
- package/src/CustomList.tsx +127 -0
- package/src/FlatList.tsx +139 -0
- package/src/PullSearch.tsx +99 -0
- package/src/ScrollView.tsx +89 -0
- package/src/ScrollViewContext.ts +50 -0
- package/src/ScrollViewFooter.tsx +48 -0
- package/src/ScrollViewHeader.tsx +159 -0
- package/src/ScrollViewProvider.tsx +62 -0
- package/src/ScrollViewSettingsContext.ts +27 -0
- package/src/ScrollViewSettingsProvider.tsx +31 -0
- package/src/SectionList.tsx +195 -0
- package/src/__mocks__/auto-paper.ts +8 -0
- package/src/__mocks__/flash-list.ts +20 -0
- package/src/__mocks__/react-native-gesture-handler.ts +13 -0
- package/src/__mocks__/react-native-keyboard-controller.ts +11 -0
- package/src/__mocks__/react-native-paper.ts +22 -0
- package/src/__mocks__/react-native-reanimated.ts +30 -0
- package/src/__mocks__/react-native-safe-area-context.ts +2 -0
- package/src/__mocks__/react-native.ts +30 -0
- package/src/__tests__/CustomList.test.tsx +61 -0
- package/src/__tests__/FlatList.test.tsx +52 -0
- package/src/__tests__/PullSearch.test.tsx +38 -0
- package/src/__tests__/ScrollView.test.tsx +23 -0
- package/src/__tests__/ScrollViewHeader.test.tsx +28 -0
- package/src/__tests__/ScrollViewProvider.test.tsx +65 -0
- package/src/__tests__/SectionList.test.tsx +88 -0
- package/src/index.ts +26 -0
- package/src/internal/FAB.tsx +6 -0
- package/src/internal/HorizontalDots.tsx +99 -0
- package/src/internal/RefreshControl.tsx +5 -0
- package/src/internal/ScrollViewChip.tsx +22 -0
- package/src/internal/useScrollHandler.ts +101 -0
- package/src/internal/useScrollList.ts +64 -0
- package/src/internal/useStickyHeaders.ts +149 -0
- package/src/out.txt +61 -0
- package/src/redux/scrollViewSlice.ts +14 -0
- package/src/useKeyboardInset.ts +21 -0
- package/src/useScrollInit.ts +112 -0
- package/src/useScrollView.ts +8 -0
- package/src/useScrollViewSettings.ts +5 -0
|
@@ -0,0 +1,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,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 })
|