@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,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,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
|
+
}
|