Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add usePager hook #798

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,48 @@ const pageScrollHandler = usePageScrollHandler({
<AnimatedPagerView onPageScroll={pageScrollHandler} />;
```

## usePager Hook Usage
The `usePager` 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.

**Warning:** The usePager hook is compatible with React version 18 and above. Ensure that your project is using an appropriate version of React before implementing this hook.

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

```jsx
// 1. Create a component that utilizes the hook within the <PagerView /> component..
<PagerView >
<HookComponent />
</PagerView>

// 2. Inside HookComponent, use the `usePager` hook.
const HookComponent = () => {
const {
page,
hasNextPage,
hasPreviousPage,
setPage,
setPageWithoutAnimation,
setScrollEnabled,
} = usePager();

return (
<View>
<Text>Current Page: {page}</Text>
<Button
title="next page"
onPress={() => {
if (hasNextPage) {
setPage(page + 1);
}
}}
/>
</View>
)
}

```


## License

MIT
2 changes: 2 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import ReanimatedOnPageScrollExample from './ReanimatedOnPageScrollExample';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { UsePagerExample } from './UsePagerExample';

const examples = [
{ component: BasicPagerViewExample, name: 'Basic Example' },
Expand Down Expand Up @@ -64,6 +65,7 @@ const examples = [
name: 'Reanimated onPageScroll example',
},
{ component: CoverflowExample, name: 'CoverflowExample' },
{ component: UsePagerExample, name: 'UsePagerExample' },
];

function App() {
Expand Down
96 changes: 96 additions & 0 deletions example/src/UsePagerExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import PagerView, { usePager } from 'react-native-pager-view';
import React from 'react';
import { View, StyleSheet, Button } from 'react-native';
import { Text } from 'react-native';

const NonHookComponent = () => {
console.log('rerender <NonHookComponent />');

return (
<View style={styles.content}>
<Text>HasNotHook</Text>
</View>
);
};

const HookComponent = ({ index }: { index: number }) => {
const {
page,
hasNextPage,
hasPreviousPage,
setPage,
setPageWithoutAnimation,
setScrollEnabled,
} = usePager();

console.log(`rerender <HookComponent index={${index}} />`);

return (
<View style={styles.content}>
<Text>Current Page: {page}</Text>
<Text>hasNextPage: {String(hasNextPage)}</Text>
<Text>hasPreviousPage: {String(hasPreviousPage)}</Text>
<Button
title="next page"
onPress={() => {
if (hasNextPage) {
setPage(page + 1);
}
}}
/>
<Button
title="prev page"
onPress={() => {
if (hasPreviousPage) {
setPage(page - 1);
}
}}
/>

<Button
title="next page without animation"
onPress={() => {
setPageWithoutAnimation(page + 1);
}}
/>

<Button
title="setScrollEnabled to true"
onPress={() => {
setScrollEnabled(true);
}}
/>

<Button
title="setScrollEnabled to false"
onPress={() => {
setScrollEnabled(false);
}}
/>
</View>
);
};

export const UsePagerExample = (): JSX.Element => {
return (
<PagerView testID="pager-view" style={styles.flex}>
<HookComponent index={1} />
<HookComponent index={2} />
<HookComponent index={3} />
<NonHookComponent />
</PagerView>
);
};

const styles = StyleSheet.create({
flex: {
flex: 1,
},
content: {
flex: 1,
marginVertical: 10,
},
separator: {
margin: 16,
},
});
48 changes: 36 additions & 12 deletions src/PagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { childrenWithOverriddenStyle } from './utils';
import PagerViewView, {
Commands as PagerViewCommands,
} from './PagerViewNativeComponent';
import { PagerStore, PagerViewContext, createPagerStore } from './usePager';

/**
* Container that allows to flip left and right between child views. Each
Expand Down Expand Up @@ -59,6 +60,12 @@ import PagerViewView, {
export class PagerView extends React.Component<PagerViewProps> {
private isScrolling = false;
pagerView: React.ElementRef<typeof PagerViewView> | null = null;
store: PagerStore | null = null;

constructor(props: PagerViewProps) {
super(props);
this.store = createPagerStore(props.initialPage ?? 0);
}

private _onPageScroll = (
e: ReactNative.NativeSyntheticEvent<OnPageScrollEventData>
Expand Down Expand Up @@ -90,6 +97,12 @@ export class PagerView extends React.Component<PagerViewProps> {
if (this.props.onPageSelected) {
this.props.onPageSelected(e);
}
this.store?.setState({
page: e.nativeEvent.position,
hasNextPage:
e.nativeEvent.position < React.Children.count(this.props.children) - 1,
hasPreviousPage: e.nativeEvent.position > 0,
});
};

/**
Expand Down Expand Up @@ -144,19 +157,30 @@ export class PagerView extends React.Component<PagerViewProps> {

render() {
return (
<PagerViewView
{...this.props}
ref={(ref) => {
this.pagerView = ref;
<PagerViewContext.Provider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high]:

React Context is a feature of React that allows you to pass data through the component tree without having to pass props down manually at every level. However, it is not a state management tool, and it has some performance implications that you should be aware of.

One of the main performance issues with React Context is that when a context value changes, all components that use the useContext hook or the Consumer component will re-render, regardless of whether they use the changed value or not. This can cause unnecessary re-rendering of components that are not affected by the context change, and thus affect the performance of your app.

[suggestion]

Would you mind creating a separate file called PagerViewContext and moving the Context there? I don’t want to affect other projects. Moreover, this is breaking changes due to behavior changes. 

After that, could you update the readme file with a section about the usePager hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your suggestion

When separating the React Context Provider into a separate file, there are issues with inserting the value. The fundamental problem is that re-rendering occurs even when the usePager hook is not used. (Originally, it was named usePagerView, but I changed it following your suggestion.)

I thought it would be good to solve the re-rendering issue without changing the way it is used. Therefore, I chose to use useSyncExternalStore to configure the store and then inject it into the context, changing the value of the store.

Since only the store’s value changes, rendering does not occur in components that do not use usePager.

However, one concern is that useSyncExternalStore is a React 18 feature, which is not compatible with earlier versions. But since the example also uses version 18, and it has been a while since version 18 was released, I believe it would be fine if we insert a warning message.

Lastly, one issue I have been contemplating is that even with the current usage, re-rendering occurs when there is a change in the page, even if one only wants to use setPage. For example, const {setPage} = usePager();

In usePager(), we could export only methods like setPage, setPageWithoutAnimation, setScrollEnabled, and construct a selector like usePagerState((state) => state.page);. This would perfectly optimize rendering only for the parts subscribing to the state, but it might make the usage somewhat more cumbersome.

If you agree with the approach I am taking, I would appreciate your opinion on this matter.

Copy link
Contributor Author

@gronxb gronxb Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <NonHookComponent /> inside the <PagerView /> does not use the usePager hook. (You can also see the code at the bottom of the video.) Additionally, I have set it up to output a console.log statement every time it renders.

The full code for this example can be found in the example code.

  • as-is render (only context)
as-is.mov
  • to-be render (using useSyncExternalStore)
to-be.mov

value={{
store: this.store,
setPage: this.setPage,
setPageWithoutAnimation: this.setPageWithoutAnimation,
setScrollEnabled: this.setScrollEnabled,
}}
style={this.props.style}
layoutDirection={this.deducedLayoutDirection}
onPageScroll={this._onPageScroll}
onPageScrollStateChanged={this._onPageScrollStateChanged}
onPageSelected={this._onPageSelected}
onMoveShouldSetResponderCapture={this._onMoveShouldSetResponderCapture}
children={childrenWithOverriddenStyle(this.props.children)}
/>
>
<PagerViewView
{...this.props}
ref={(ref) => {
this.pagerView = ref;
}}
style={this.props.style}
layoutDirection={this.deducedLayoutDirection}
onPageScroll={this._onPageScroll}
onPageScrollStateChanged={this._onPageScrollStateChanged}
onPageSelected={this._onPageSelected}
onMoveShouldSetResponderCapture={
this._onMoveShouldSetResponderCapture
}
children={childrenWithOverriddenStyle(this.props.children)}
/>
</PagerViewContext.Provider>
);
}
}
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type * as ReactNative from 'react-native';
import { PagerView } from './PagerView';
export default PagerView;
export { usePager } from './usePager';
export type { PagerState } from './usePager';

import type {
OnPageScrollEventData as PagerViewOnPageScrollEventData,
Expand Down
71 changes: 71 additions & 0 deletions src/usePager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useContext, useSyncExternalStore } from 'react';

export type PagerViewContextValue = {
store: PagerStore | null;
setPage: (selectedPage: number) => void;
setPageWithoutAnimation: (selectedPage: number) => void;
setScrollEnabled: (scrollEnabled: boolean) => void;
};

export type PagerState = {
page: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};

export type PagerStore = {
getState: () => PagerState;
setState: (state: PagerState) => void;
subscribe: (listener: () => void) => () => void;
};

export const PagerViewContext =
React.createContext<PagerViewContextValue | null>(null);

export const createPagerStore = (initialPage: number) => {
let state: PagerState = {
page: initialPage,
hasNextPage: false,
hasPreviousPage: false,
};

const getState = () => {
return state;
};

const listeners = new Set<() => void>();

const emitChange = () => {
for (const listener of listeners) {
listener();
}
};

const setState = (newState: PagerState) => {
state = newState;
emitChange();
};

const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};

return { getState, setState, subscribe };
};

export const usePager = () => {
if (Number(React.version.split('.')[0]) < 18) {
throw new Error('usePager requires React 18 or later.');
}
const value = useContext(PagerViewContext);

if (!value || !value.store) {
throw new Error('usePager must be used within a <PagerView /> component');
}

const { store, ...methods } = value;

const state = useSyncExternalStore(store.subscribe, store.getState);
return { ...methods, ...state };
};
Loading