React-native-pager-view: ☂️ [6.x-RC] Dynamic / lazy approach to load pages

Created on 20 Nov 2019  ·  15Comments  ·  Source: callstack/react-native-pager-view

Feature Request

Why it is needed

Performance way to load a LOT of pages dynamically to create a truly dynamic swiper.

Possible implementation / Code sample

Right now I can do something like:

const [position, setPosition] = useState(0);

const onPageSelected = e => {
  setPosition(e.nativeEvent.position);
};

const min = 0;
const max = position + numberOfPages;

const items = data.slice(min, max).map((item, index) => (
  // If they are are not inside the range, we render null to get a better performance
  <View key={item}>
    {index < position + numberOfPages && index > position - numberOfPages ? (
      <Item item={item} />
    ) : null}
  </View>
));

<ViewPager 
  onPageSelected={onPageSelected}>
   ... 
>
  {items}
</ViewPager>

Contraints: List keeps growing while you swipe.

A better way instead of rendering null would be to slice from the beginning too:

const min = position - numberOfPages;
const max = position + numberOfPages;

However, this approach has a problem. Consider the scenario:

  • Pages: 1 2 3 4 and position = 2 (selected element is 3).

We slice from the beginning and we render:

  • Pages 2 3 4 5 but still position = 2 (selected element will be 4). <-- The problem is that if we change the children in this way, we need to adapt the position (here the position should be 1 for selected element to be still 3).

Another approach would be doing this by default natively: https://github.com/react-native-community/react-native-viewpager/issues/83.

enhancement next

Most helpful comment

I really need this feature

All 15 comments

I really need this feature

Basic js (ts) side implementation. Works well enough for my purposes. You may be able to adapt.

import React, {
  forwardRef,
  Ref,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import {NativeSyntheticEvent, StyleProp, View, ViewStyle} from 'react-native';
import ViewPager, {
  ViewPagerOnPageSelectedEventData,
} from '@react-native-community/viewpager';

type PageSelectedEvent = NativeSyntheticEvent<ViewPagerOnPageSelectedEventData>;

export type RenderItem<T> = (info: {
  item: T;
  itemIndex: number;
  visiblePage: number;
}) => React.ReactElement;

export type LazyViewPagerHandle = {setPage(selectedPage: number): void};

interface LazyViewPagerProps<T> {
  /**
   * Number of items to render before and after the current page. Default 1.
   */
  buffer?: number;
  data: T[];
  /**
   * Index of starting page.
   */
  initialPage?: number;
  onPageSelected?: (page: number) => void;
  renderItem: RenderItem<T>;
  style?: StyleProp<ViewStyle>;
}

function computeOffset(page: number, numPages: number, buffer: number) {
  const windowLength = 1 + 2 * buffer;
  let offset: number;
  if (page <= buffer || numPages <= windowLength) {
    offset = 0;
  } else if (page >= 1 + numPages - windowLength) {
    offset = Math.max(0, numPages - windowLength);
  } else {
    offset = page - buffer;
  }
  return offset;
}

function sleep(milliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

function renderPage<T>(
  renderItem: RenderItem<T>,
  item: T,
  itemIndex: number,
  visiblePage: number,
  buffer: number,
) {
  const delta = Math.abs(itemIndex - visiblePage);
  return (
    <View key={itemIndex}>
      {delta <= buffer ? renderItem({item, itemIndex, visiblePage}) : null}
    </View>
  );
}

function LazyViewPagerImpl<T>(
  props: LazyViewPagerProps<T>,
  ref: Ref<LazyViewPagerHandle>,
) {
  // Internal buffer is larger; supports paging.
  const internalBuffer = 8;
  const buffer =
    (props.buffer == null ? 1 : Math.max(0, props.buffer)) + internalBuffer;

  // When set to `true`, forces `ViewPager` to remount.
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [page, setPage] = useState(() =>
    props.initialPage == null ? 0 : Math.max(0, props.initialPage),
  );
  const [offset, setOffset] = useState(() =>
    computeOffset(page, props.data.length, buffer),
  );
  const targetOffset = useRef(offset);
  const vpRef = useRef<ViewPager>(null);

  const onPageSelected = (event: PageSelectedEvent) => {
    if (offset === targetOffset.current) {
      setPage(event.nativeEvent.position + offset);
    }
  };

  useEffect(() => {
    if (isRefreshing) {
      setIsRefreshing(false);
    }
  }, [isRefreshing, setIsRefreshing]);

  useEffect(() => {
    const state = {live: true};
    // Rate limit offset updates.
    sleep(1100).then(() => {
      if (state.live) {
        targetOffset.current = computeOffset(page, props.data.length, buffer);
        setOffset(targetOffset.current);
      }
    });
    return () => {
      state.live = false;
    };
  }, [buffer, page, props.data.length, setOffset, targetOffset]);

  // Broadcast page selected event.
  const clientOnPageSelected = props.onPageSelected;
  useEffect(() => {
    if (clientOnPageSelected != null) {
      clientOnPageSelected(page);
    }
  }, [clientOnPageSelected, page]);

  const windowLength = 1 + 2 * buffer;

  useImperativeHandle(
    ref,
    () => ({
      setPage: (selectedPage: number) => {
        if (vpRef.current != null) {
          const vpPage = selectedPage - offset;
          if (vpPage >= 0 && vpPage < windowLength) {
            // Inside render window, navigate normally.
            vpRef.current.setPage(vpPage);
            return;
          }
        }

        // Remount component to navigate to `selectedPage`.
        // TODO: Is there a cleaner way that does not involve forcing a
        //       rebuild of `ViewPager`?
        const newOffset = computeOffset(
          selectedPage,
          props.data.length,
          buffer,
        );
        targetOffset.current = newOffset;
        setOffset(newOffset);
        setPage(selectedPage);
        setIsRefreshing(true);
      },
    }),
    [
      buffer,
      offset,
      props.data.length,
      setIsRefreshing,
      setOffset,
      setPage,
      targetOffset,
      vpRef,
      windowLength,
    ],
  );

  return isRefreshing ? (
    <View style={props.style} />
  ) : (
    <ViewPager
      initialPage={page - offset}
      ref={vpRef}
      style={props.style}
      onPageSelected={onPageSelected}>
      {props.data
        .slice(offset, offset + windowLength)
        .map((item, index) =>
          renderPage(
            props.renderItem,
            item,
            offset + index,
            page,
            buffer - internalBuffer,
          ),
        )}
    </ViewPager>
  );
}

export const LazyViewPager = forwardRef(LazyViewPagerImpl);

Also forwardRef() lacks generic support, so I used:

import React from 'react';

declare module 'react' {
  // Redefine to better support generics.
  function forwardRef<T, P = {}>(
    render: (props: P, ref: React.Ref<T>) => React.ReactElement | null,
  ): (
    props: React.PropsWithoutRef<P> & React.RefAttributes<T>,
  ) => React.ReactElement | null;
}

Hey!
Any updates on this? Is this somewhere in the pipeline?

i want lazy loading anyone has another ways?

@alpha0010 Thanks for sharing your solution. I've verified it does work, with one caveat. When I prepend the list, it looses the position of the current page. If I make the buffer sufficiently large, this doesn't happen, but performance degrades. It also doesn't happen using vanilla react-native-pager. Wondering if you have any thoughts as to why this might be the case? I'm hoping for a way to rectify.

Do you have any solutions? We need this function very much. Thank you very much.

This feature has been implemented here:
https://github.com/callstack/react-native-pager-view/releases/tag/v6.0.0-rc.0

Any feedback will be appreciated

I have found one improvable thing:
On Android the new page is rendered when the swipe is released and it blocks the UI thread, due to this the onPageScroll event is stunned on the last offset.
I suggest that next render should start after onPageSelected event.
Anyways thanks @alpha0010 for this feature!

I have found one improvable thing:
On Android the new page is rendered when the swipe is released and it blocks the UI thread, due to this the onPageScroll event is stunned on the last offset.
I suggest that next render should start after onPageSelected event.

Do you have any recommendation how to do so? It is already wrapped with requestAnimationFrame() https://github.com/callstack/react-native-pager-view/blob/306d8997c703ee6884ed6fa2bd85db5ad3659bfc/src/LazyPagerView.tsx#L243-L254 .

Hmm... simply placing it as the last thing in the function doesn't help?

Hey! quick questions if you people can please answer,

  1. What's the difference between flatlist and viewpager?
    I am implementing flatlist with horizontal scroll which loads data dynamically, it has a paging option and horizontal scroll as well

  2. Is there anyway to preload data just like flatlist provides via threshold

  1. Flatlist manages a consecutive sequence of views. If these views are the same size as the screen, and scroll snapping is enabled, it will function similarly to the viewpager. However the location is a pixel offset from the top of the flatlist. This can most easily observed on device rotation (will see parts of the adjacent views, and likely end up on a different view than prior to rotate). Viewpager manages pages. Each page is a view. Events and operations work at page level; compare to flatlist where pixel level (which can be translated back to pages with a bit of math).

    • Flatlist can work as a viewpager, but viewpager cannot work as a flatlist. For page level operations, viewpager will be simpler and require fewer workarounds.

  2. Not quite the same: set buffer appropriately; have pages begin loading data on mount.

Hey Alpha,

Thank you for the reply.

Considering my every view has full width and height, I will stick to
flatlist then.

My usecase is basically each card taking up the screen and horizontal
scroll.

On Fri, Jul 2, 2021, 1:56 AM Alpha @.*> wrote:

>

  1. Flatlist manages a consecutive sequence of views. If these views
    are the same size as the screen, and scroll snapping is enabled, it will
    function similarly to the viewpager. However the location is a pixel offset
    from the top of the flatlist. This can most easily observed on device
    rotation (will see parts of the adjacent views, and likely end up on a
    different view than prior to rotate). Viewpager manages pages. Each page is
    a view. Events and operations work at page level; compare to flatlist where
    pixel level (which can be translated back to pages with a bit of math).

    • Flatlist can work as a viewpager, but viewpager cannot work as a

      flatlist. For page level operations, viewpager will be simpler and require

      fewer workarounds.

  2. Not quite the same: set buffer
    https://github.com/callstack/react-native-pager-view/blob/next/src/types.ts#L145
    appropriately; have pages begin loading data on mount.


You are receiving this because you commented.
Reply to this email directly, view it on GitHub
https://github.com/callstack/react-native-pager-view/issues/104#issuecomment-872528841,
or unsubscribe
https://github.com/notifications/unsubscribe-auth/AI36LMJSWAKUGPQ7IOSZIHDTVTFPDANCNFSM4JPRFHOQ
.

Was this page helpful?
0 / 5 - 0 ratings