diff --git a/src/components/LazyFlatList.tsx b/src/components/LazyFlatList.tsx new file mode 100644 index 0000000..8ab9364 --- /dev/null +++ b/src/components/LazyFlatList.tsx @@ -0,0 +1,97 @@ +import React, { + ForwardedRef, + MutableRefObject, + RefAttributes, + forwardRef, + useImperativeHandle, +} from 'react'; +import { FlatList } from 'react-native'; +import Animated, { useAnimatedRef } from 'react-native-reanimated'; +import { AnimatedContext } from '../context/AnimatedContext'; +import { useLazyContextValues } from './useLazyContextValues'; + +export interface LazyFlatListMethods { + scrollToStart: typeof FlatList.prototype.scrollToEnd; + scrollToEnd: typeof FlatList.prototype.scrollToEnd; + scrollToIndex: typeof FlatList.prototype.scrollToIndex; + scrollToOffset: typeof FlatList.prototype.scrollToOffset; + scrollToItem: typeof FlatList.prototype.scrollToItem; +} + +export interface LazyFlatListProps { + /** + * How far above or below the bottom of the FlatList the threshold trigger is. Negative is above, postive it below. Accepts [FlatList props](https://reactnative.dev/docs/FlatList). + * @defaultValue 0 (bottom of Fl) + */ + offset?: number; + /** + * Ref to the LazyFlatList. Exposes scrollTo, scrollToStart, and scrollToEnd methods. + */ + ref?: MutableRefObject; + /** + * When true, console.logs FlatList measurements. Even if true, will not call console.log in production. + */ + debug?: boolean; +} + +type Props = LazyFlatListProps & + Omit< + React.ComponentProps>, + 'onLayout' | 'onScroll' | 'ref' + >; + +/** + * LazyFlatList to wrap Lazy Children in. + */ +const UnwrappedLazyFlatList = ( + { offset: injectedOffset, debug = false, ...rest }: Props, + ref: ForwardedRef +) => { + const _flatListRef = useAnimatedRef>(); + + useImperativeHandle(ref, () => ({ + scrollToStart: (options) => { + _flatListRef.current?.scrollToOffset({ + offset: 0, + animated: options?.animated, + }); + }, + scrollToEnd: (options) => { + _flatListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + _flatListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + _flatListRef.current?.scrollToOffset(options); + }, + scrollToItem: (options) => { + _flatListRef.current?.scrollToItem(options); + }, + })); + + const { value, measureScroller } = useLazyContextValues({ + offset: injectedOffset, + debug, + horizontal: rest.horizontal, + // @ts-ignore + ref: _flatListRef, + }); + + return ( + + + + ); +}; + +const LazyFlatList = forwardRef(UnwrappedLazyFlatList) as ( + props: Props & RefAttributes +) => React.ReactElement; + +export { LazyFlatList }; diff --git a/src/components/LazyScrollView.tsx b/src/components/LazyScrollView.tsx index 33ea72c..437d68f 100644 --- a/src/components/LazyScrollView.tsx +++ b/src/components/LazyScrollView.tsx @@ -1,26 +1,12 @@ import React, { MutableRefObject, forwardRef, - useCallback, useImperativeHandle, - useMemo, } from 'react'; -import { ScrollView, StatusBar, type LayoutChangeEvent } from 'react-native'; -import Animated, { - measure, - runOnJS, - runOnUI, - useAnimatedRef, - useDerivedValue, - useScrollViewOffset, - useSharedValue, -} from 'react-native-reanimated'; +import { ScrollView } from 'react-native'; +import Animated, { useAnimatedRef } from 'react-native-reanimated'; import { AnimatedContext } from '../context/AnimatedContext'; -import { logger } from '../utils/logger'; - -const log = (...args: Parameters) => { - logger.log('', ...args); -}; +import { useLazyContextValues } from './useLazyContextValues'; export interface LazyScrollViewMethods { scrollTo: typeof ScrollView.prototype.scrollTo; @@ -56,12 +42,6 @@ type Props = LazyScrollViewProps & const LazyScrollView = forwardRef( ({ children, offset: injectedOffset, debug = false, ...rest }, ref) => { const _scrollRef = useAnimatedRef(); - const _offset = useSharedValue(injectedOffset || 0); - const _containerDimensions = useSharedValue({ width: 0, height: 0 }); - const _containerCoordinates = useSharedValue({ x: 0, y: 0 }); - const _statusBarHeight = useSharedValue(StatusBar.currentHeight || 0); - const _debug = useSharedValue(debug); - const horizontal = useSharedValue(!!rest.horizontal); useImperativeHandle(ref, () => ({ scrollTo: (options) => { @@ -79,89 +59,19 @@ const LazyScrollView = forwardRef( }, })); - /** - * Starts at 0 and increases as the user scrolls down - */ - const scrollValue = useScrollViewOffset(_scrollRef); - - const containerStart = useDerivedValue(() => - horizontal.value - ? _containerCoordinates.value.x - : _containerCoordinates.value.y - ); - const containerEnd = useDerivedValue( - () => - (horizontal.value - ? _containerDimensions.value.width - : _containerDimensions.value.height) + containerStart.value - ); - - const startTrigger = useDerivedValue( - () => containerStart.value - _offset.value - ); - const endTrigger = useDerivedValue( - () => containerEnd.value + _offset.value - ); - - const measureScrollView = useCallback( - (e: LayoutChangeEvent) => { - _containerDimensions.value = { - height: e.nativeEvent.layout.height, - width: e.nativeEvent.layout.width, - }; - - if (debug) { - log('dimensions:', { - height: _containerDimensions.value.height, - width: _containerDimensions.value.width, - }); - } - - // onLayout runs when RN finishes render, but native layout may not be fully settled until the next frame. - requestAnimationFrame(() => { - runOnUI(() => { - 'worklet'; - const measurement = measure(_scrollRef); - - if (measurement) { - const coordinates = { - x: measurement.pageX, - y: measurement.pageY + _statusBarHeight.value, - }; - - if (_debug.value) { - runOnJS(log)('coordinates:', coordinates); - } - - _containerCoordinates.value = coordinates; - } - })(); - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders - [] - ); - - const value = useMemo( - () => ({ - _hasProvider: true, - scrollValue, - containerStart, - containerEnd, - startTrigger, - endTrigger, - horizontal, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders - [] - ); + const { value, measureScroller } = useLazyContextValues({ + offset: injectedOffset, + debug, + horizontal: rest.horizontal, + ref: _scrollRef, + }); return ( {children} diff --git a/src/components/useLazyContextValues.ts b/src/components/useLazyContextValues.ts new file mode 100644 index 0000000..2803272 --- /dev/null +++ b/src/components/useLazyContextValues.ts @@ -0,0 +1,116 @@ +import React, { useCallback, useMemo } from 'react'; +import { StatusBar, type LayoutChangeEvent } from 'react-native'; +import Animated, { + AnimatedRef, + measure, + runOnJS, + runOnUI, + useDerivedValue, + useScrollViewOffset, + useSharedValue, +} from 'react-native-reanimated'; +import { logger } from '../utils/logger'; +import { LazyScrollViewProps } from './LazyScrollView'; + +const log = (...args: Parameters) => { + logger.log('', ...args); +}; + +type Props = Omit & + Pick, 'horizontal'> & { + ref: AnimatedRef; + }; + +/** + * ScrollView to wrap Lazy Children in. + */ +export const useLazyContextValues = ({ + offset: injectedOffset, + debug = false, + horizontal, + ref, +}: Props) => { + const _offset = useSharedValue(injectedOffset || 0); + const _containerDimensions = useSharedValue({ width: 0, height: 0 }); + const _containerCoordinates = useSharedValue({ x: 0, y: 0 }); + const _statusBarHeight = useSharedValue(StatusBar.currentHeight || 0); + const _debug = useSharedValue(debug); + const _horizontal = useSharedValue(!!horizontal); + + /** + * Starts at 0 and increases as the user scrolls down + */ + const scrollValue = useScrollViewOffset(ref); + + const containerStart = useDerivedValue(() => + _horizontal.value + ? _containerCoordinates.value.x + : _containerCoordinates.value.y + ); + const containerEnd = useDerivedValue( + () => + (_horizontal.value + ? _containerDimensions.value.width + : _containerDimensions.value.height) + containerStart.value + ); + + const startTrigger = useDerivedValue( + () => containerStart.value - _offset.value + ); + const endTrigger = useDerivedValue(() => containerEnd.value + _offset.value); + + const measureScroller = useCallback( + (e: LayoutChangeEvent) => { + _containerDimensions.value = { + height: e.nativeEvent.layout.height, + width: e.nativeEvent.layout.width, + }; + + if (debug) { + log('dimensions:', { + height: _containerDimensions.value.height, + width: _containerDimensions.value.width, + }); + } + + // onLayout runs when RN finishes render, but native layout may not be fully settled until the next frame. + requestAnimationFrame(() => { + runOnUI(() => { + 'worklet'; + const measurement = measure(ref); + + if (measurement) { + const coordinates = { + x: measurement.pageX, + y: measurement.pageY + _statusBarHeight.value, + }; + + if (_debug.value) { + runOnJS(log)('coordinates:', coordinates); + } + + _containerCoordinates.value = coordinates; + } + })(); + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders + [] + ); + + const value = useMemo( + () => ({ + _hasProvider: true, + scrollValue, + containerStart, + containerEnd, + startTrigger, + endTrigger, + horizontal: _horizontal, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values do not trigger re-renders + [] + ); + + return { value, measureScroller }; +}; diff --git a/src/index.ts b/src/index.ts index c6c30f5..ba530e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './components/LazyChild'; export * from './components/LazyScrollView'; +export * from './components/LazyFlatList';