callstack / react-native-pager-view

React Native wrapper for the Android ViewPager and iOS UIPageViewController.
MIT License
2.71k stars 418 forks source link

Support synchronous onScroll events on the UI thread by using Reanimated #316

Open mrousavy opened 3 years ago

mrousavy commented 3 years ago

Describe the feature

Currently the onPageScroll, onPageSelected and onPageScrollStateChanged events are dispatched as bubbling events using the batched bridge. This is really slow and doesn't allow for any smooth (60 FPS) interpolations based on scroll state, such as smooth title changes etc.

I'm suggesting to support synchronous callbacks by using the Reanimated APIs (worklets on the UI thread), which shouldn't be too hard to implement and allow for perfectly smooth 60 FPS animations all without crossing threads and spamming the bridge (see react-native-gesture-handler, they do the same thing with the onGesture event.)

Motivation

Implementation

I wanted to do the same thing for a gyroscope library but haven't come around to actually implement it. I believe reanimated already does a lot of work for you, but I don't exactly know how this is going to work. I'll have to take a look myself, but it looks like they also just have an RCTEventDispatcher set up, which then simply sends those events... šŸ¤” So I think there are no native code changes required here, but we somehow need a hook similar to the useAnimatedGestureHandler hook.

Stevemoretz commented 3 years ago

This is a great idea, if the library is based on a ScrollView component it already supports reanimated2 for example FlatList is based on ScrollView and reanimated supports it out of the box.

mrousavy commented 3 years ago

as stated in the second sentence on this project's README, it is not based on a ScrollView.

Stevemoretz commented 3 years ago

Oops, in that case if the native code could imitate what scrollview native code does, (the specific part that reanimated uses) this could also be automatically supported, I don't exactly know what's happening behind the scenes, but just wanted to throw an idea maybe could be useful.

By the way the function you should be linking to is useAnimatedScrollHandler not gesture handler.

mrousavy commented 3 years ago

This PR has been merged, allowing the low-level useHandler hooks to be used.

I don't have an app anymore that uses this Pager View library, but I will start working on this feature once reanimated ships a release anyway just because I think the challenge is interesting šŸ˜…

mateusz1913 commented 3 years ago

@mrousavy Idk if making reanimated lib a dependency is best solution here. Maybe better option would be to just add a README section or some additional code in example folder, to showcase how users can do it. Some users would have an opportunity to create a custom hook that could handle all 3 events, or 3 hooks, per each event. And it would not be a breaking change for devs who use that lib, but do not use reanimated at all

Stevemoretz commented 3 years ago

This PR has been merged, allowing the low-level useHandler hooks to be used.

I don't have an app anymore that uses this Pager View library, but I will start working on this feature once reanimated ships a release anyway just because I think the challenge is interesting šŸ˜…

Pretty cool! That would be great.

izakfilmalter commented 2 years ago

I was able to do the following to get it working with reanimated. This is a modified version of react-native-tab-view:

import type {
  EventEmitterProps,
  Listener,
  NavigationState,
  PagerProps,
  Route,
} from 'Components/react-native-tab-view/types'
import {
  DependencyList,
  ReactElement,
  ReactNode,
  useCallback,
  useEffect,
  useRef,
} from 'react'
import { Keyboard, StyleSheet } from 'react-native'
import ViewPager from 'react-native-pager-view'
import {
  PagerViewOnPageScrollEventData,
  PageScrollStateChangedEvent,
} from 'react-native-pager-view/src/types'
import Animated, {
  add,
  call,
  useEvent,
  useHandler,
  useSharedValue,
} from 'react-native-reanimated'

const AnimatedViewPager = Animated.createAnimatedComponent(ViewPager)

export function usePagerScrollHandler(
  handlers: {
    onPageScroll: (event: PagerViewOnPageScrollEventData, context: {}) => void
  },
  dependencies?: DependencyList,
) {
  const { context, doDependenciesDiffer } = useHandler(handlers, dependencies)
  const subscribeForEvents = ['onPageScroll']

  return useEvent<PagerViewOnPageScrollEventData>(
    (event) => {
      'worklet'
      const { onPageScroll } = handlers

      // console.log(event)

      if (onPageScroll) {
        onPageScroll(event, context)
      }
    },
    subscribeForEvents,
    doDependenciesDiffer,
  )
}

export function usePageScrollStateChangedHandler(
  handlers: {
    idle?: (event: PageScrollStateChangedEvent, context: {}) => void
    dragging?: (event: PageScrollStateChangedEvent, context: {}) => void
    settling?: (event: PageScrollStateChangedEvent, context: {}) => void
  },
  dependencies?: DependencyList,
) {
  const { context, doDependenciesDiffer } = useHandler(handlers, dependencies)
  const subscribeForEvents = ['onPageScroll']

  return useEvent<PageScrollStateChangedEvent>(
    (event) => {
      'worklet'
      const { idle, dragging, settling } = handlers
      const { pageScrollState } = event

      if (idle && pageScrollState === 'idle') {
        idle(event, context)
      }

      if (dragging && pageScrollState === 'dragging') {
        dragging(event, context)
      }

      if (settling && pageScrollState === 'settling') {
        settling(event, context)
      }
    },
    subscribeForEvents,
    doDependenciesDiffer,
  )
}

type Props<T extends Route> = PagerProps & {
  onIndexChange: (index: number) => void
  navigationState: NavigationState<T>
  children: (
    props: EventEmitterProps & {
      // Animated value which represents the state of current index
      // It can include fractional digits as it represents the intermediate value
      position: ReturnType<typeof add>
      // Function to actually render the content of the pager
      // The parent component takes care of rendering
      render: (children: ReactNode) => ReactNode
      // Callback to call when switching the tab
      // The tab switch animation is performed even if the index in state is unchanged
      jumpTo: (key: string) => void
    },
  ) => ReactElement
}

export function Pager<T extends Route>({
  keyboardDismissMode = 'auto',
  swipeEnabled = true,
  navigationState,
  onIndexChange,
  onSwipeStart,
  onSwipeEnd,
  children,
  style,
  ...rest
}: Props<T>) {
  const { index } = navigationState

  const listenersRef = useRef<Array<Listener>>([])

  const pagerRef = useRef<ViewPager | null>()
  const indexRef = useRef<number>(index)
  const navigationStateRef = useRef(navigationState)

  const position = useSharedValue(index)
  const offset = useSharedValue(0)

  useEffect(() => {
    navigationStateRef.current = navigationState
  })

  const jumpTo = useCallback((key: string) => {
    const index = navigationStateRef.current.routes.findIndex(
      (route: { key: string }) => route.key === key,
    )

    pagerRef.current?.setPage(index)
  }, [])

  useEffect(() => {
    if (keyboardDismissMode === 'auto') {
      Keyboard.dismiss()
    }

    if (indexRef.current !== index) {
      pagerRef.current?.setPage(index)
    }
  }, [keyboardDismissMode, index])

  const newOnPageScrollStateChanged = usePageScrollStateChangedHandler({
    idle: () => {
      'worklet'
      onSwipeEnd?.()
      return
    },
    dragging: () => {
      call([offset.value], ([x]) => {
        const next = index + (x > 0 ? Math.ceil(x) : Math.floor(x))

        if (next !== index) {
          listenersRef.current.forEach((listener) => listener(next))
        }
      })

      onSwipeStart?.()
      return
    },
  })

  const addEnterListener = useCallback((listener: Listener) => {
    listenersRef.current.push(listener)

    return () => {
      const index = listenersRef.current.indexOf(listener)

      if (index > -1) {
        listenersRef.current.splice(index, 1)
      }
    }
  }, [])

  const handler = usePagerScrollHandler({
    onPageScroll: (event) => {
      'worklet'
      position.value = event.position
      offset.value = event.offset
    },
  })

  return children({
    position: add(position.value, offset.value),
    addEnterListener,
    jumpTo,
    render: (children) => (
      <AnimatedViewPager
        {...rest}
        ref={(x) => (pagerRef.current = x as ViewPager | null)}
        style={[styles.container, style]}
        initialPage={index}
        keyboardDismissMode={
          keyboardDismissMode === 'auto' ? 'on-drag' : keyboardDismissMode
        }
        onPageScroll={handler}
        onPageSelected={(e) => {
          const index = e.nativeEvent.position
          indexRef.current = index
          onIndexChange(index)
        }}
        onPageScrollStateChanged={newOnPageScrollStateChanged}
        scrollEnabled={swipeEnabled}
      >
        {children}
      </AnimatedViewPager>
    ),
  })
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
})