diff --git a/README.md b/README.md index 66f6906..1378e2b 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,27 @@ This library requires reanimated. Follow their [installation instructions](https ```js // MyCoolHomeScreen.tsx import { LazyScrollView } from 'react-native-lazy-scrollview'; -import { CoolComponentA, CoolComponentB, CoolComponentC } from './components'; +import { + CoolComponentA, + CoolComponentB, + CoolComponentC, + PriceMasterVideo, + ScrollToTopButton, +} from './components'; export function MyCoolHomeScreen() { + const ref = useRef(null); + return ( // Trigger onThresholdReached when child is 300 pixels below the bottom - + + ref.current?.scrollToStart({ animated: true })} + /> ); } diff --git a/example/src/App.tsx b/example/src/App.tsx index f4e8465..a6a70a8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { random } from 'lodash'; import shuffle from 'lodash/shuffle'; @@ -10,7 +10,10 @@ import { TouchableOpacity, View, } from 'react-native'; -import { LazyScrollView } from 'react-native-lazy-scrollview'; +import { + LazyScrollView, + LazyScrollViewMethods, +} from 'react-native-lazy-scrollview'; import { ColorBlock } from './components/ColorBlock'; const ALBUMS: ImageSourcePropType = [ @@ -35,6 +38,8 @@ const OFFSET = -100; const SHUFFLED_ALBUMS = shuffle(ALBUMS); function VerticalScrollView() { + const ref = useRef(null); + const renderBlock = useCallback( (source: ImageSourcePropType | null, i: number) => ( @@ -45,12 +50,28 @@ function VerticalScrollView() { return ( {SHUFFLED_ALBUMS.map(renderBlock)} + + ref.current?.scrollToStart({ animated: true })} + > + ⬆️ + + ref.current?.scrollToEnd({ animated: true })} + > + ⬇️ + + {`Offset: ${OFFSET}`} @@ -182,4 +203,13 @@ const styles = StyleSheet.create({ marginBottom: 16, marginTop: PADDING_VERTICAL * 2, }, + arrowsContainer: { + top: 0, + bottom: 0, + right: 0, + position: 'absolute', + justifyContent: 'center', + }, + arrowButton: { marginBottom: 8 }, + arrow: { fontSize: 32 }, }); diff --git a/src/components/LazyScrollView.tsx b/src/components/LazyScrollView.tsx index 6ece04a..72c0416 100644 --- a/src/components/LazyScrollView.tsx +++ b/src/components/LazyScrollView.tsx @@ -1,5 +1,16 @@ -import React, { useCallback, useRef } from 'react'; -import { StatusBar, View, type LayoutChangeEvent } from 'react-native'; +import React, { + MutableRefObject, + forwardRef, + useCallback, + useImperativeHandle, + useRef, +} from 'react'; +import { + ScrollView, + StatusBar, + View, + type LayoutChangeEvent, +} from 'react-native'; import Animated, { useAnimatedRef, useDerivedValue, @@ -8,12 +19,22 @@ import Animated, { } from 'react-native-reanimated'; import { AnimatedContext } from '../context/AnimatedContext'; +export interface LazyScrollViewMethods { + scrollTo: typeof ScrollView.prototype.scrollTo; + scrollToStart: typeof ScrollView.prototype.scrollToEnd; + scrollToEnd: typeof ScrollView.prototype.scrollToEnd; +} + export interface LazyScrollViewProps { /** * How far above or below the bottom of the ScrollView the threshold trigger is. Negative is above, postive it below. Accepts [ScrollView props](https://reactnative.dev/docs/scrollview). * @defaultValue 0 (bottom of ScrollView) */ offset?: number; + /** + * Ref to the LazyScrollView. Exposes scrollTo, scrollToStart, and scrollToEnd methods. + */ + ref?: MutableRefObject; } type Props = LazyScrollViewProps & @@ -25,79 +46,95 @@ type Props = LazyScrollViewProps & /** * ScrollView to wrap Lazy Children in. */ -export function LazyScrollView({ - children, - offset: injectedOffset, - ...rest -}: Props) { - const _scrollRef = useAnimatedRef(); - const _wrapperRef = useRef(null); - const _offset = useSharedValue(injectedOffset || 0); - const _containerHeight = useSharedValue(0); - const _contentHeight = useSharedValue(0); - const _hasProvider = useSharedValue(true); +const LazyScrollView = forwardRef( + ({ children, offset: injectedOffset, ...rest }, ref) => { + const _scrollRef = useAnimatedRef(); + const _wrapperRef = useRef(null); + const _offset = useSharedValue(injectedOffset || 0); + const _containerHeight = useSharedValue(0); + const _contentHeight = useSharedValue(0); + const _hasProvider = useSharedValue(true); - /** - * Starts at 0 and increases as the user scrolls down - */ - const scrollValue = useScrollViewOffset(_scrollRef); + useImperativeHandle(ref, () => ({ + scrollTo: (options) => { + _scrollRef.current?.scrollTo(options); + }, + scrollToStart: (options) => { + _scrollRef.current?.scrollTo({ + x: 0, + y: 0, + animated: options?.animated, + }); + }, + scrollToEnd: (options) => { + _scrollRef.current?.scrollToEnd(options); + }, + })); + + /** + * Starts at 0 and increases as the user scrolls down + */ + const scrollValue = useScrollViewOffset(_scrollRef); - const topYValue = useSharedValue(0); - const bottomYValue = useDerivedValue( - () => _containerHeight.value + topYValue.value - ); + const topYValue = useSharedValue(0); + const bottomYValue = useDerivedValue( + () => _containerHeight.value + topYValue.value + ); - const topTriggerValue = useDerivedValue( - () => topYValue.value - _offset.value - ); - const bottomTriggerValue = useDerivedValue( - () => bottomYValue.value + _offset.value - ); + const topTriggerValue = useDerivedValue( + () => topYValue.value - _offset.value + ); + const bottomTriggerValue = useDerivedValue( + () => bottomYValue.value + _offset.value + ); - const onLayout = useCallback( - (e: LayoutChangeEvent) => { - _containerHeight.value = e.nativeEvent.layout.height; - _wrapperRef.current?.measureInWindow( - (_: number, y: number, _2: number, height: number) => { - topYValue.value = y + (StatusBar.currentHeight || 0); - _contentHeight.value = height; - } - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders - [] - ); + const onLayout = useCallback( + (e: LayoutChangeEvent) => { + _containerHeight.value = e.nativeEvent.layout.height; + _wrapperRef.current?.measureInWindow( + (_: number, y: number, _2: number, height: number) => { + topYValue.value = y + (StatusBar.currentHeight || 0); + _contentHeight.value = height; + } + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders + [] + ); - const onContentContainerLayout = useCallback( - (e: LayoutChangeEvent) => { - _contentHeight.value = e.nativeEvent.layout.height; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders - [] - ); + const onContentContainerLayout = useCallback( + (e: LayoutChangeEvent) => { + _contentHeight.value = e.nativeEvent.layout.height; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders + [] + ); - return ( - - - - {children} - - - - ); -} + + + {children} + + + + ); + } +); + +export { LazyScrollView };