Skip to content

Commit

Permalink
feat(js): add view pager hook (#873)
Browse files Browse the repository at this point in the history
* feat(js): add view pager hook

* fix types

* fix(class-exporting): Add Omit type for exclude private methods.

* docs: usePager Hook Usage section

* fix(readme): Readme update.

* fix(readme): Update hook params && README

* fix(lint): Fix for lint

---------

Co-authored-by: Piotr Trocki <piotr.trocki@callstack.com>
Co-authored-by: Vladislav Bataev <bataevvlad@gmail.com>
Co-authored-by: gronxb <gron1gh1@gmail.com>
  • Loading branch information
4 people committed Sep 12, 2024
1 parent d26b793 commit 057d8e7
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 7 deletions.
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,70 @@ const pageScrollHandler = usePageScrollHandler({
<AnimatedPagerView onPageScroll={pageScrollHandler} />;
```

## usePagerView Hook Usage
The `usePagerView` hook is a convenient way to manage the state and control the behavior of the `<PagerView />` component. It provides functions and variables to interact with the pager, such as navigating between pages and enabling/disabling scrolling.

Below is an example of how to use the usePager hook:

```jsx
export function PagerHookExample() {
const { AnimatedPagerView, ref, ...rest } = usePagerView({ pagesAmount: 10 });

return (
<SafeAreaView style={styles.container}>
<AnimatedPagerView
testID="pager-view"
ref={ref}
style={styles.PagerView}
initialPage={0}
layoutDirection="ltr"
overdrag={rest.overdragEnabled}
scrollEnabled={rest.scrollEnabled}
onPageScroll={rest.onPageScroll}
onPageSelected={rest.onPageSelected}
onPageScrollStateChanged={rest.onPageScrollStateChanged}
pageMargin={10}
orientation="horizontal"
>
{useMemo(
() =>
rest.pages.map((_, index) => (
<View
testID="pager-view-content"
key={index}
style={{
flex: 1,
backgroundColor: '#fdc08e',
alignItems: 'center',
padding: 20,
}}
collapsable={false}
>
<LikeCount />
<Text testID={`pageNumber${index}`}>
{`page number ${index}`}
</Text>
</View>
)),
[rest.pages]
)}
</AnimatedPagerView>
<NavigationPanel {...rest} />
</SafeAreaView>
);
}
```
### How the Example Works:

- **Pager View Setup**: The `AnimatedPagerView` component wraps `PagerView` in React Native's animation capabilities. It accepts multiple props from the `usePager` hook, such as `overdragEnabled`, `scrollEnabled`, `onPageScroll`, `onPageSelected`, and others to manage pager behavior.

- **Rendering Pages**: The pages are dynamically generated using the `rest.pages` array (initialized by `usePager`). The `useMemo` hook ensures the pages are only recomputed when necessary for performance reasons.

### Conclusion

The `usePager` hook makes it easy to handle pagination with dynamic views. This example demonstrates how to set up a simple paginated interface where users can scroll through pages, interact with page elements, and control the pager with external navigation.


## License

MIT
2 changes: 2 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ import ReanimatedOnPageScrollExample from './ReanimatedOnPageScrollExample';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NextBasicPagerViewExample } from './NextBasicPagerViewExample';
import { PagerHookExample } from './PagerHookExample';

const examples = [
{ component: BasicPagerViewExample, name: 'Basic Example' },
{ component: PagerHookExample, name: 'Pager Hook Example' },
{ component: KeyboardExample, name: 'Keyboard Example' },
{ component: OnPageScrollExample, name: 'OnPageScroll Example' },
{ component: OnPageSelectedExample, name: 'OnPageSelected Example' },
Expand Down
71 changes: 71 additions & 0 deletions example/src/PagerHookExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useMemo } from 'react';
import { StyleSheet, View, SafeAreaView, Text } from 'react-native';

import { LikeCount } from './component/LikeCount';
import { NavigationPanel } from './component/NavigationPanel';
import { usePagerView } from 'react-native-pager-view';

export function PagerHookExample() {
const { AnimatedPagerView, ref, ...rest } = usePagerView({ pagesAmount: 10 });

return (
<SafeAreaView style={styles.container}>
<AnimatedPagerView
// @ts-ignore
testID="pager-view"
ref={ref}
style={styles.PagerView}
initialPage={0}
layoutDirection="ltr"
overdrag={rest.overdragEnabled}
scrollEnabled={rest.scrollEnabled}
onPageScroll={rest.onPageScroll}
onPageSelected={rest.onPageSelected}
onPageScrollStateChanged={rest.onPageScrollStateChanged}
pageMargin={10}
// Lib does not support dynamically orientation change
orientation="horizontal"
>
{useMemo(
() =>
rest.pages.map((_, index) => (
<View
testID="pager-view-content"
key={index}
style={{
flex: 1,
backgroundColor: '#fdc08e',
alignItems: 'center',
padding: 20,
}}
collapsable={false}
>
<LikeCount />
<Text
testID={`pageNumber${index}`}
>{`page number ${index}`}</Text>
</View>
)),
[rest.pages]
)}
</AnimatedPagerView>
{/*@ts-ignore*/}
<NavigationPanel {...rest} />
</SafeAreaView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},
image: {
width: 300,
height: 200,
padding: 20,
},
PagerView: {
flex: 1,
},
});
33 changes: 26 additions & 7 deletions src/PagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ import LEGACY_PagerViewNativeComponent, {
* ```
*/

// Type that excludes the private methods
// Fix for class exporting using hook.
type PublicPagerViewMethods = Omit<
PagerViewInternal,
| 'nativeCommandsWrapper'
| 'deducedLayoutDirection'
| '_onPageScroll'
| '_onPageScrollStateChanged'
| '_onPageSelected'
| '_onMoveShouldSetResponderCapture'
>;

class PagerViewInternal extends React.Component<NativeProps> {
private isScrolling = false;
pagerView: React.ElementRef<typeof PagerViewNativeComponent> | null = null;
Expand Down Expand Up @@ -216,15 +228,22 @@ class PagerViewInternal extends React.Component<NativeProps> {
// Temporary solution. It should be removed once all things get fixed
type PagerViewProps = Omit<NativeProps, 'useLegacy'> & { useNext?: boolean };

export const PagerView = React.forwardRef<PagerViewInternal, PagerViewProps>(
(props, ref) => {
const { useNext, ...rest } = props;
return <PagerViewInternal {...rest} useLegacy={!useNext} ref={ref} />;
}
);
export const PagerView = React.forwardRef<
PublicPagerViewMethods,
PagerViewProps
>((props, ref) => {
const { useNext, ...rest } = props;
return (
<PagerViewInternal
{...rest}
useLegacy={!useNext}
ref={ref as React.LegacyRef<PagerViewInternal>}
/>
);
});

// React.forwardRef does not type returned component properly, thus breaking Ref<MyComponent> typing.
// One way to overcome this is using separate typing for component "interface",
// but that breaks backward compatibility in this case.
// Approach of merging type is hacky, but produces a good typing for both ref attributes and component itself.
export type PagerView = PagerViewInternal & typeof PagerView;
export type PagerView = PublicPagerViewMethods & typeof PagerView;
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type * as ReactNative from 'react-native';
import { PagerView } from './PagerView';
export default PagerView;
export * from './usePagerView';

import type {
OnPageScrollEventData as PagerViewOnPageScrollEventData,
Expand Down
146 changes: 146 additions & 0 deletions src/usePagerView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type * as ReactNative from 'react-native';
import type {
OnPageScrollEventData as PagerViewOnPageScrollEventData,
OnPageSelectedEventData as PagerViewOnPageSelectedEventData,
OnPageScrollStateChangedEventData as PageScrollStateChangedNativeEventData,
} from './specs/PagerViewNativeComponent';

type PageScrollStateChangedNativeEvent =
ReactNative.NativeSyntheticEvent<PageScrollStateChangedNativeEventData>;

import { PagerView } from './PagerView';

import { Animated } from 'react-native';
import { useCallback, useMemo, useRef, useState } from 'react';

export type UsePagerViewProps = ReturnType<typeof usePagerView>;

const AnimatedPagerView = Animated.createAnimatedComponent(PagerView);

type UsePagerViewParams = {
pagesAmount: number;
};

export function usePagerView(
{ pagesAmount }: UsePagerViewParams = { pagesAmount: 0 }
) {
const ref = useRef<PagerView>(null);
const [pages, setPages] = useState<number[]>(
new Array(pagesAmount).fill('').map((_v, index) => index)
);
const [activePage, setActivePage] = useState(0);
const [isAnimated, setIsAnimated] = useState(true);
const [overdragEnabled, setOverdragEnabled] = useState(false);
const [scrollEnabled, setScrollEnabled] = useState(true);
const [scrollState, setScrollState] = useState('idle');
const [progress, setProgress] = useState({ position: 0, offset: 0 });
const onPageScrollOffset = useRef(new Animated.Value(0)).current;
const onPageScrollPosition = useRef(new Animated.Value(0)).current;
const onPageSelectedPosition = useRef(new Animated.Value(0)).current;

const setPage = useCallback(
(page: number) =>
isAnimated
? ref.current?.setPage(page)
: ref.current?.setPageWithoutAnimation(page),
[isAnimated]
);

const addPage = useCallback(() => {
setPages((prevPages) => {
const lastPageNumber = prevPages[prevPages.length - 1];
if (lastPageNumber) {
return [...prevPages, lastPageNumber + 1];
}
return prevPages;
});
}, []);

const removePage = useCallback(() => {
setPages((prevPages) => {
if (prevPages.length === 1) {
return prevPages;
}
return prevPages.slice(0, prevPages.length - 1);
});
}, []);

const toggleAnimation = useCallback(
() => setIsAnimated((animated) => !animated),
[]
);

const toggleScroll = useCallback(
() => setScrollEnabled((enabled) => !enabled),
[]
);

const toggleOverdrag = useCallback(
() => setOverdragEnabled((enabled) => !enabled),
[]
);

const onPageScroll = useMemo(
() =>
Animated.event<PagerViewOnPageScrollEventData>(
[
{
nativeEvent: {
offset: onPageScrollOffset,
position: onPageScrollPosition,
},
},
],
{
useNativeDriver: true,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

const onPageSelected = useMemo(
() =>
Animated.event<PagerViewOnPageSelectedEventData>(
[{ nativeEvent: { position: onPageSelectedPosition } }],
{
listener: ({ nativeEvent: { position } }) => {
setActivePage(position);
},
useNativeDriver: true,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

const onPageScrollStateChanged = useCallback(
(e: PageScrollStateChangedNativeEvent) => {
setScrollState(e.nativeEvent.pageScrollState);
},
[]
);

return {
ref,
activePage,
isAnimated,
pages,
scrollState,
scrollEnabled,
progress,
overdragEnabled,
setPage,
addPage,
removePage,
toggleScroll,
toggleAnimation,
setProgress,
onPageScroll,
onPageSelected,
onPageScrollStateChanged,
toggleOverdrag,
AnimatedPagerView,
PagerView,
};
}

0 comments on commit 057d8e7

Please sign in to comment.