@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,61 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { CustomList } from '../CustomList'
5
+ import { ScrollViewProvider } from '../ScrollViewProvider'
6
+
7
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
+
9
+ type MockListProps = {
10
+ contentInset?: unknown
11
+ contentOffset?: unknown
12
+ onScroll?: unknown
13
+ scrollEventThrottle?: number
14
+ onMomentumScrollEnd?: unknown
15
+ onScrollBeginDrag?: unknown
16
+ onScrollEndDrag?: unknown
17
+ [key: string]: unknown
18
+ }
19
+
20
+ const MockList = jest.fn((_props: MockListProps) => null)
21
+
22
+ describe('CustomList', () => {
23
+ beforeEach(() => MockList.mockClear())
24
+
25
+ it('renders without crashing', () => {
26
+ render(<CustomList component={MockList} />, { wrapper })
27
+ })
28
+
29
+ it('wires up contentInset and contentOffset', () => {
30
+ render(<CustomList component={MockList} />, { wrapper })
31
+ const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
32
+ expect(props).toHaveProperty('contentInset')
33
+ expect(props).toHaveProperty('contentOffset')
34
+ })
35
+
36
+ it('passes scrollEventThrottle={16}', () => {
37
+ render(<CustomList component={MockList} />, { wrapper })
38
+ const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
39
+ expect(props?.scrollEventThrottle).toBe(16)
40
+ })
41
+
42
+ it('passes onScroll handler', () => {
43
+ render(<CustomList component={MockList} />, { wrapper })
44
+ const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
45
+ expect(typeof props?.onScroll).toBe('function')
46
+ })
47
+
48
+ it('passes momentum and drag scroll handlers', () => {
49
+ render(<CustomList component={MockList} />, { wrapper })
50
+ const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
51
+ expect(typeof props?.onMomentumScrollEnd).toBe('function')
52
+ expect(typeof props?.onScrollBeginDrag).toBe('function')
53
+ expect(typeof props?.onScrollEndDrag).toBe('function')
54
+ })
55
+
56
+ it('forwards extra props to the inner component', () => {
57
+ render(<CustomList component={MockList} testID='my-list' />, { wrapper })
58
+ const props = MockList.mock.calls[0]?.[0] as MockListProps | undefined
59
+ expect(props?.testID).toBe('my-list')
60
+ })
61
+ })
@@ -0,0 +1,52 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { FlatList } from '../FlatList'
5
+ import { ScrollViewProvider } from '../ScrollViewProvider'
6
+
7
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
+
9
+ describe('FlatList', () => {
10
+ it('renders without crashing', () => {
11
+ render(<FlatList data={[]} renderItem={() => null} />, { wrapper })
12
+ })
13
+
14
+ it('renders with data items', () => {
15
+ render(
16
+ <FlatList data={['a', 'b', 'c']} renderItem={({ item }) => <>{item}</>} keyExtractor={(item) => item} />,
17
+ { wrapper }
18
+ )
19
+ })
20
+
21
+ it('renders horizontal', () => {
22
+ render(<FlatList data={[]} renderItem={() => null} horizontal />, { wrapper })
23
+ })
24
+
25
+ it('renders horizontal paged list with HorizontalDots for multiple items', () => {
26
+ render(
27
+ <FlatList
28
+ data={['a', 'b', 'c']}
29
+ renderItem={({ item }) => <>{item}</>}
30
+ keyExtractor={(item) => item}
31
+ horizontal
32
+ pagingEnabled
33
+ />,
34
+ { wrapper }
35
+ )
36
+ })
37
+
38
+ it('renders with pullSearchHeight', () => {
39
+ render(<FlatList data={[]} renderItem={() => null} pullSearchHeight={60} />, { wrapper })
40
+ })
41
+
42
+ it('renders with renderFilters', () => {
43
+ render(
44
+ <FlatList data={[]} renderItem={() => null} renderFilters={<></>} />,
45
+ { wrapper }
46
+ )
47
+ })
48
+
49
+ it('renders with keyboardAware', () => {
50
+ render(<FlatList data={[]} renderItem={() => null} keyboardAware />, { wrapper })
51
+ })
52
+ })
@@ -0,0 +1,38 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { PullSearch, type PullSearchHandle } from '../PullSearch'
5
+ import { ScrollViewProvider } from '../ScrollViewProvider'
6
+
7
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
+
9
+ describe('PullSearch', () => {
10
+ it('renders without crashing', () => {
11
+ render(<PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
12
+ })
13
+
14
+ it('renders with placeholder', () => {
15
+ render(
16
+ <PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} placeholder='Search items...' />,
17
+ { wrapper }
18
+ )
19
+ })
20
+
21
+ it('renders with initial value', () => {
22
+ render(<PullSearch onChangeText={jest.fn()} onHeightChange={jest.fn()} value='hello' />, { wrapper })
23
+ })
24
+
25
+ it('exposes blur and focus via imperative ref', () => {
26
+ const ref = React.createRef<PullSearchHandle>()
27
+ render(<PullSearch ref={ref} onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
28
+ expect(typeof ref.current?.blur).toBe('function')
29
+ expect(typeof ref.current?.focus).toBe('function')
30
+ })
31
+
32
+ it('does not throw when blur/focus called with no input mounted', () => {
33
+ const ref = React.createRef<PullSearchHandle>()
34
+ render(<PullSearch ref={ref} onChangeText={jest.fn()} onHeightChange={jest.fn()} />, { wrapper })
35
+ expect(() => ref.current?.blur()).not.toThrow()
36
+ expect(() => ref.current?.focus()).not.toThrow()
37
+ })
38
+ })
@@ -0,0 +1,23 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { ScrollView } from '../ScrollView'
5
+ import { ScrollViewProvider } from '../ScrollViewProvider'
6
+
7
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
8
+
9
+ describe('ScrollView', () => {
10
+ it('renders without crashing', () => {
11
+ render(<ScrollView />, { wrapper })
12
+ })
13
+
14
+ it('renders children', () => {
15
+ const { getByText } = render(
16
+ <ScrollView>
17
+ <></>
18
+ </ScrollView>,
19
+ { wrapper }
20
+ )
21
+ expect(getByText).toBeDefined()
22
+ })
23
+ })
@@ -0,0 +1,28 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { ScrollViewFooter } from '../ScrollViewFooter'
5
+ import { ScrollViewHeader } from '../ScrollViewHeader'
6
+ import { ScrollViewProvider } from '../ScrollViewProvider'
7
+
8
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
9
+
10
+ describe('ScrollViewHeader', () => {
11
+ it('renders without crashing', () => {
12
+ render(<ScrollViewHeader />, { wrapper })
13
+ })
14
+
15
+ it('renders with children', () => {
16
+ render(<ScrollViewHeader><></></ScrollViewHeader>, { wrapper })
17
+ })
18
+ })
19
+
20
+ describe('ScrollViewFooter', () => {
21
+ it('renders without crashing', () => {
22
+ render(<ScrollViewFooter />, { wrapper })
23
+ })
24
+
25
+ it('renders with children', () => {
26
+ render(<ScrollViewFooter><></></ScrollViewFooter>, { wrapper })
27
+ })
28
+ })
@@ -0,0 +1,65 @@
1
+ import { render } from '@testing-library/react'
2
+ import React, { useContext } from 'react'
3
+
4
+ import { ScrollViewContext } from '../ScrollViewContext'
5
+ import { ScrollViewProvider } from '../ScrollViewProvider'
6
+ import { useScrollView } from '../useScrollView'
7
+
8
+ const ContextReader = ({ onRead }: { onRead: (v: ReturnType<typeof useScrollView>) => void }) => {
9
+ const value = useScrollView()
10
+ onRead(value)
11
+ return null
12
+ }
13
+
14
+ describe('ScrollViewProvider', () => {
15
+ it('renders without crashing', () => {
16
+ render(
17
+ <ScrollViewProvider>
18
+ <></>
19
+ </ScrollViewProvider>
20
+ )
21
+ })
22
+
23
+ it('provides default context values', () => {
24
+ let capturedValue: ReturnType<typeof useScrollView> | undefined
25
+ render(
26
+ <ScrollViewProvider>
27
+ <ContextReader onRead={(v) => { capturedValue = v }} />
28
+ </ScrollViewProvider>
29
+ )
30
+ expect(capturedValue?.progress).toBeNull()
31
+ expect(capturedValue?.progressing).toBe(false)
32
+ })
33
+
34
+ it('accepts headerFixed and footerFixed props', () => {
35
+ let capturedCtx: { headerFixed: boolean; footerFixed: boolean } | undefined
36
+ const Reader = () => {
37
+ const ctx = useContext(ScrollViewContext)
38
+ capturedCtx = { headerFixed: ctx.headerFixed, footerFixed: ctx.footerFixed }
39
+ return null
40
+ }
41
+ render(
42
+ <ScrollViewProvider headerFixed footerFixed>
43
+ <Reader />
44
+ </ScrollViewProvider>
45
+ )
46
+ expect(capturedCtx?.headerFixed).toBe(true)
47
+ expect(capturedCtx?.footerFixed).toBe(true)
48
+ })
49
+
50
+ it('setProgress updates progress value', () => {
51
+ let capturedValue: ReturnType<typeof useScrollView> | undefined
52
+ const { rerender } = render(
53
+ <ScrollViewProvider>
54
+ <ContextReader onRead={(v) => { capturedValue = v }} />
55
+ </ScrollViewProvider>
56
+ )
57
+ capturedValue?.setProgress(0.5)
58
+ rerender(
59
+ <ScrollViewProvider>
60
+ <ContextReader onRead={(v) => { capturedValue = v }} />
61
+ </ScrollViewProvider>
62
+ )
63
+ expect(capturedValue?.progress).toBe(0.5)
64
+ })
65
+ })
@@ -0,0 +1,88 @@
1
+ import { render } from '@testing-library/react'
2
+ import React from 'react'
3
+ import { SectionList as RNSectionList } from 'react-native'
4
+
5
+ import { SectionList } from '../SectionList'
6
+ import { ScrollViewProvider } from '../ScrollViewProvider'
7
+
8
+ const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
9
+
10
+ type Section = { title: string; data: string[] }
11
+ const sections: Section[] = [
12
+ { title: 'Fruits', data: ['Apple', 'Banana'] },
13
+ { title: 'Veggies', data: ['Carrot'] }
14
+ ]
15
+
16
+ describe('SectionList', () => {
17
+ beforeEach(() => {
18
+ ;(RNSectionList as jest.Mock).mockClear()
19
+ })
20
+
21
+ it('renders without crashing', () => {
22
+ render(
23
+ <SectionList sections={sections} renderItem={({ item }) => <>{item}</>} keyExtractor={(item) => item} />,
24
+ { wrapper }
25
+ )
26
+ })
27
+
28
+ it('renders with renderSectionHeader', () => {
29
+ render(
30
+ <SectionList
31
+ sections={sections}
32
+ renderItem={({ item }) => <>{item}</>}
33
+ keyExtractor={(item) => item}
34
+ renderSectionHeader={({ section }) => <>{(section as Section).title}</>}
35
+ />,
36
+ { wrapper }
37
+ )
38
+ })
39
+
40
+ it('disables native stickySectionHeadersEnabled when custom renderSectionHeader is provided', () => {
41
+ render(
42
+ <SectionList
43
+ sections={sections}
44
+ renderItem={({ item }) => <>{item}</>}
45
+ keyExtractor={(item) => item}
46
+ renderSectionHeader={({ section }) => <>{(section as Section).title}</>}
47
+ />,
48
+ { wrapper }
49
+ )
50
+ const calls = (RNSectionList as jest.Mock).mock.calls
51
+ const lastProps = calls[calls.length - 1]?.[0]
52
+ expect(lastProps?.stickySectionHeadersEnabled).toBe(false)
53
+ })
54
+
55
+ it('keeps native sticky when no renderSectionHeader provided', () => {
56
+ render(
57
+ <SectionList sections={sections} renderItem={({ item }) => <>{item}</>} keyExtractor={(item) => item} />,
58
+ { wrapper }
59
+ )
60
+ const calls = (RNSectionList as jest.Mock).mock.calls
61
+ const lastProps = calls[calls.length - 1]?.[0]
62
+ expect(lastProps?.stickySectionHeadersEnabled).toBe(false)
63
+ })
64
+
65
+ it('renders with pullSearchHeight', () => {
66
+ render(
67
+ <SectionList
68
+ sections={sections}
69
+ renderItem={({ item }) => <>{item}</>}
70
+ keyExtractor={(item) => item}
71
+ pullSearchHeight={60}
72
+ />,
73
+ { wrapper }
74
+ )
75
+ })
76
+
77
+ it('renders with renderFilters', () => {
78
+ render(
79
+ <SectionList
80
+ sections={sections}
81
+ renderItem={({ item }) => <>{item}</>}
82
+ keyExtractor={(item) => item}
83
+ renderFilters={<></>}
84
+ />,
85
+ { wrapper }
86
+ )
87
+ })
88
+ })
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type { CustomListProps } from './CustomList'
2
+ export { CustomList } from './CustomList'
3
+ export type { FlatListProps } from './FlatList'
4
+ export { FlatList } from './FlatList'
5
+ export type { PullSearchHandle, PullSearchProps } from './PullSearch'
6
+ export { PullSearch } from './PullSearch'
7
+ export { scrollViewActions, scrollViewReducer } from './redux/scrollViewSlice'
8
+ export type { ScrollViewProps } from './ScrollView'
9
+ export { ScrollView } from './ScrollView'
10
+ export type { ScrollViewContextType } from './ScrollViewContext'
11
+ export { ScrollViewContext } from './ScrollViewContext'
12
+ export type { ScrollViewFooterProps } from './ScrollViewFooter'
13
+ export { ScrollViewFooter } from './ScrollViewFooter'
14
+ export type { ScrollViewHeaderProps } from './ScrollViewHeader'
15
+ export { ScrollViewHeader } from './ScrollViewHeader'
16
+ export type { ScrollViewProviderProps } from './ScrollViewProvider'
17
+ export { ScrollViewProvider } from './ScrollViewProvider'
18
+ export type { ScrollViewSettings, ScrollViewSettingsContextType } from './ScrollViewSettingsContext'
19
+ export { defaultScrollViewSettings, ScrollViewSettingsContext } from './ScrollViewSettingsContext'
20
+ export type { ScrollViewSettingsProviderProps } from './ScrollViewSettingsProvider'
21
+ export { ScrollViewSettingsProvider } from './ScrollViewSettingsProvider'
22
+ export type { SectionListProps } from './SectionList'
23
+ export { SectionList } from './SectionList'
24
+ export { useKeyboardInset } from './useKeyboardInset'
25
+ export { useScrollView } from './useScrollView'
26
+ export { useScrollViewSettings } from './useScrollViewSettings'
@@ -0,0 +1,6 @@
1
+ import { FAB as AutoPaperFAB } from '@rific/auto-paper'
2
+ import { FABProps } from 'react-native-paper'
3
+
4
+ type InternalFABProps = Pick<FABProps, 'icon' | 'onPress' | 'style'>
5
+
6
+ export const FAB = (props: InternalFABProps) => <AutoPaperFAB animated={false} label='' size='small' {...props} />
@@ -0,0 +1,99 @@
1
+ import { BlurView } from '@rific/auto-paper'
2
+ import { useContext } from 'react'
3
+ import { StyleSheet, useWindowDimensions, View } from 'react-native'
4
+ import { useTheme } from 'react-native-paper'
5
+ import type { SharedValue } from 'react-native-reanimated'
6
+ import Animated, { interpolateColor, useAnimatedStyle } from 'react-native-reanimated'
7
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
8
+
9
+ import { ScrollViewContext } from '../ScrollViewContext'
10
+
11
+ const DOT_FULL = 8
12
+ const DOT_SMALL = 3
13
+ const DOT_GAP = 6
14
+ const DOT_STEP = DOT_FULL + DOT_GAP
15
+ const CLIP_COUNT = 5
16
+ const CLIP_WIDTH = CLIP_COUNT * DOT_STEP - DOT_GAP
17
+ const PILL_PAD_H = 10
18
+ const PILL_PAD_V = 6
19
+ const PILL_HEIGHT = DOT_FULL + PILL_PAD_V * 2
20
+ const PILL_RADIUS = PILL_HEIGHT / 2
21
+ const PILL_WIDTH = CLIP_WIDTH + PILL_PAD_H * 2
22
+
23
+ type DotProps = {
24
+ activeColor: string
25
+ index: number
26
+ inactiveColor: string
27
+ pageWidth: number
28
+ scrollPosition: SharedValue<number>
29
+ }
30
+
31
+ const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: DotProps) => {
32
+ const circleStyle = useAnimatedStyle(() => {
33
+ const page = scrollPosition.value / pageWidth
34
+ const distance = Math.abs(index - page)
35
+ const t = Math.max(0, Math.min((distance - 0.5) / 2, 1))
36
+ const size = DOT_FULL + (DOT_SMALL - DOT_FULL) * t
37
+ const backgroundColor = interpolateColor(t, [0, 1], [activeColor, inactiveColor])
38
+ return { backgroundColor, borderRadius: size / 2, height: size, width: size }
39
+ })
40
+ return (
41
+ <View style={styles.dotContainer}>
42
+ <Animated.View style={circleStyle} />
43
+ </View>
44
+ )
45
+ }
46
+
47
+ type Props = { total: number }
48
+
49
+ export const HorizontalDots = ({ total }: Props) => {
50
+ const { blur, footerHeightShared, scrollPosition } = useContext(ScrollViewContext)
51
+ const { width } = useWindowDimensions()
52
+ const insets = useSafeAreaInsets()
53
+ const theme = useTheme()
54
+ const insetsBottom = insets.bottom
55
+
56
+ const activeColor = theme.colors.primary
57
+ const inactiveColor = theme.colors.outlineVariant
58
+
59
+ const pillContainerStyle = useAnimatedStyle(() => ({
60
+ bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
61
+ }))
62
+
63
+ const rowStyle = useAnimatedStyle(() => {
64
+ const page = scrollPosition.value / width
65
+ const maxOffset = Math.max(0, (total - CLIP_COUNT) * DOT_STEP)
66
+ const offset = Math.max(0, Math.min((page - (CLIP_COUNT - 1) / 2) * DOT_STEP, maxOffset))
67
+ return { transform: [{ translateX: -offset }] }
68
+ })
69
+
70
+ return (
71
+ <Animated.View pointerEvents='none' style={[styles.pillContainer, pillContainerStyle]}>
72
+ <View style={styles.pill}>
73
+ <BlurView blur={blur} style={StyleSheet.absoluteFill} />
74
+ <View style={styles.dotsClip}>
75
+ <Animated.View style={[styles.dotsRow, rowStyle]}>
76
+ {Array.from({ length: total }).map((_, i) => (
77
+ <Dot key={i} activeColor={activeColor} index={i} inactiveColor={inactiveColor} pageWidth={width} scrollPosition={scrollPosition} />
78
+ ))}
79
+ </Animated.View>
80
+ </View>
81
+ </View>
82
+ </Animated.View>
83
+ )
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ dotContainer: { alignItems: 'center', height: DOT_FULL, justifyContent: 'center', width: DOT_FULL },
88
+ dotsClip: { overflow: 'hidden', width: CLIP_WIDTH },
89
+ dotsRow: { flexDirection: 'row', gap: DOT_GAP },
90
+ pill: {
91
+ alignItems: 'center',
92
+ borderRadius: PILL_RADIUS,
93
+ height: PILL_HEIGHT,
94
+ justifyContent: 'center',
95
+ overflow: 'hidden',
96
+ width: PILL_WIDTH
97
+ },
98
+ pillContainer: { alignItems: 'center', left: 0, position: 'absolute', right: 0, zIndex: 3 }
99
+ })
@@ -0,0 +1,5 @@
1
+ import { RefreshControl as RNRefreshControl, type RefreshControlProps } from 'react-native'
2
+
3
+ export type { RefreshControlProps }
4
+
5
+ export const RefreshControl = (props: Partial<RefreshControlProps>) => <RNRefreshControl refreshing={false} {...props} />
@@ -0,0 +1,22 @@
1
+ import { StyleSheet, type ViewStyle } from 'react-native'
2
+ import { Chip } from 'react-native-paper'
3
+ import Animated, { type AnimatedStyle } from 'react-native-reanimated'
4
+
5
+ type Props = {
6
+ isHorizontal?: boolean
7
+ onPress: () => void
8
+ style: AnimatedStyle<ViewStyle>
9
+ }
10
+
11
+ export const ScrollViewChip = ({ isHorizontal, onPress, style }: Props) => (
12
+ <Animated.View style={[styles.chip, style]}>
13
+ <Chip compact icon={isHorizontal ? 'chevron-left' : 'chevron-up'} onPress={onPress} style={styles.chipInner}>
14
+ {isHorizontal ? 'Start' : 'Top'}
15
+ </Chip>
16
+ </Animated.View>
17
+ )
18
+
19
+ const styles = StyleSheet.create({
20
+ chip: { alignItems: 'center', left: 0, position: 'absolute', right: 0, zIndex: 3 },
21
+ chipInner: { elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15, shadowRadius: 2 }
22
+ })
@@ -0,0 +1,101 @@
1
+ import { useContext } from 'react'
2
+ import type { SharedValue } from 'react-native-reanimated'
3
+ import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming } from 'react-native-reanimated'
4
+
5
+ import { ScrollViewContext } from '../ScrollViewContext'
6
+
7
+ type UseScrollHandlerOptions = {
8
+ chipHidden: SharedValue<number>
9
+ footerFixed: boolean
10
+ headerFixed: boolean
11
+ isHorizontal?: boolean
12
+ onPullSearchZoneEnter?: () => void
13
+ }
14
+
15
+ export function useScrollHandler({ chipHidden, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter }: UseScrollHandlerOptions) {
16
+ const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
17
+ const snapUpAccum = useSharedValue(0)
18
+ const fromBottomBounce = useSharedValue(false)
19
+ const isMomentum = useSharedValue(false)
20
+ const momentumPullSearchFired = useSharedValue(false)
21
+
22
+ return useAnimatedScrollHandler(
23
+ {
24
+ onScroll: ({ contentOffset: { x, y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
25
+ 'worklet'
26
+ if (isHorizontal) {
27
+ scrollPosition.value = x
28
+ chipHidden.value = x < 100 ? 1 : 0
29
+ return
30
+ }
31
+ const delta = y - scrollPosition.value
32
+ scrollPosition.value = y
33
+ chipHidden.value = y < 100 ? 1 : 0
34
+ const maxScroll = contentHeight - layoutHeight
35
+ if (maxScroll > 0 && y >= maxScroll) {
36
+ fromBottomBounce.value = true
37
+ snapUpAccum.value = 0
38
+ } else if (fromBottomBounce.value) {
39
+ fromBottomBounce.value = false
40
+ }
41
+ if (isMomentum.value && pullSearchHeightShared.value > 0 && !momentumPullSearchFired.value) {
42
+ const hideY = -headerHeightShared.value + pullSearchHeightShared.value
43
+ if (y < hideY) {
44
+ momentumPullSearchFired.value = true
45
+ if (onPullSearchZoneEnter) runOnJS(onPullSearchZoneEnter)()
46
+ }
47
+ }
48
+ const snapHeader = snapBackHeaderShared.value && !headerFixed
49
+ const snapFooter = snapBackFooterShared.value && !footerFixed
50
+ if (snapHeader || snapFooter) {
51
+ if (y <= -headerHeightShared.value) {
52
+ snapUpAccum.value = 0
53
+ if (snapHeader) headerOffset.value = 0
54
+ if (snapFooter) footerOffset.value = 0
55
+ } else if (delta > 0) {
56
+ snapUpAccum.value = 0
57
+ if (y >= -headerHeightShared.value + pullSearchHeightShared.value) {
58
+ if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
59
+ if (snapFooter) footerOffset.value = Math.max(0, Math.min(footerHeightShared.value, footerOffset.value + delta))
60
+ }
61
+ } else if (delta < 0 && !fromBottomBounce.value) {
62
+ if (y >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.value -= delta
63
+ if (snapUpAccum.value >= 10) {
64
+ snapUpAccum.value = 999
65
+ if (snapHeader) headerOffset.value = withTiming(0, { duration: 200 })
66
+ if (snapFooter) footerOffset.value = withTiming(0, { duration: 200 })
67
+ }
68
+ }
69
+ }
70
+ },
71
+ onBeginDrag: () => {
72
+ 'worklet'
73
+ isMomentum.value = false
74
+ momentumPullSearchFired.value = false
75
+ fromBottomBounce.value = false
76
+ snapUpAccum.value = 0
77
+ },
78
+ onMomentumBegin: () => {
79
+ 'worklet'
80
+ isMomentum.value = true
81
+ momentumPullSearchFired.value = false
82
+ },
83
+ onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
84
+ 'worklet'
85
+ if (isHorizontal) return
86
+ if (y >= contentHeight - layoutHeight - 10) {
87
+ fromBottomBounce.value = true
88
+ snapUpAccum.value = 0
89
+ }
90
+ },
91
+ onMomentumEnd: () => {
92
+ 'worklet'
93
+ isMomentum.value = false
94
+ momentumPullSearchFired.value = false
95
+ fromBottomBounce.value = false
96
+ snapUpAccum.value = 0
97
+ }
98
+ },
99
+ [headerFixed, footerFixed, isHorizontal, onPullSearchZoneEnter]
100
+ )
101
+ }