Open mrousavy opened 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.
as stated in the second sentence on this project's README, it is not based on a ScrollView.
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.
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 š
@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
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.
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,
},
})
Describe the feature
Currently the
onPageScroll
,onPageSelected
andonPageScrollStateChanged
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 theuseAnimatedGestureHandler
hook.