Skip to content

Commit

Permalink
feat: add LazyFlatList
Browse files Browse the repository at this point in the history
  • Loading branch information
johnhaup committed Oct 24, 2024
1 parent 358be76 commit b9b0fce
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 100 deletions.
97 changes: 97 additions & 0 deletions src/components/LazyFlatList.tsx
Original file line number Diff line number Diff line change
@@ -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<LazyFlatListMethods | null>;
/**
* When true, console.logs FlatList measurements. Even if true, will not call console.log in production.
*/
debug?: boolean;
}

type Props<T> = LazyFlatListProps &
Omit<
React.ComponentProps<typeof Animated.FlatList<T>>,
'onLayout' | 'onScroll' | 'ref'
>;

/**
* LazyFlatList to wrap Lazy Children in.
*/
const UnwrappedLazyFlatList = <T,>(
{ offset: injectedOffset, debug = false, ...rest }: Props<T>,
ref: ForwardedRef<LazyFlatListMethods>
) => {
const _flatListRef = useAnimatedRef<Animated.FlatList<T>>();

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 (
<AnimatedContext.Provider value={value}>
<Animated.FlatList
scrollEventThrottle={16}
{...rest}
ref={_flatListRef}
onLayout={measureScroller}
/>
</AnimatedContext.Provider>
);
};

const LazyFlatList = forwardRef(UnwrappedLazyFlatList) as <T>(
props: Props<T> & RefAttributes<LazyFlatListMethods>
) => React.ReactElement;

export { LazyFlatList };
110 changes: 10 additions & 100 deletions src/components/LazyScrollView.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof console.log>) => {
logger.log('<LazyScrollView>', ...args);
};
import { useLazyContextValues } from './useLazyContextValues';

export interface LazyScrollViewMethods {
scrollTo: typeof ScrollView.prototype.scrollTo;
Expand Down Expand Up @@ -56,12 +42,6 @@ type Props = LazyScrollViewProps &
const LazyScrollView = forwardRef<LazyScrollViewMethods, Props>(
({ children, offset: injectedOffset, debug = false, ...rest }, ref) => {
const _scrollRef = useAnimatedRef<Animated.ScrollView>();
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) => {
Expand All @@ -79,89 +59,19 @@ const LazyScrollView = forwardRef<LazyScrollViewMethods, Props>(
},
}));

/**
* 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 (
<Animated.ScrollView
scrollEventThrottle={16}
{...rest}
ref={_scrollRef}
onLayout={measureScrollView}
onLayout={measureScroller}
>
<AnimatedContext.Provider value={value}>
{children}
Expand Down
116 changes: 116 additions & 0 deletions src/components/useLazyContextValues.ts
Original file line number Diff line number Diff line change
@@ -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<typeof console.log>) => {
logger.log('<LazyScrollView>', ...args);
};

type Props = Omit<LazyScrollViewProps, 'ref'> &
Pick<React.ComponentProps<typeof Animated.ScrollView>, 'horizontal'> & {
ref: AnimatedRef<Animated.ScrollView>;
};

/**
* 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 };
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components/LazyChild';
export * from './components/LazyScrollView';
export * from './components/LazyFlatList';

0 comments on commit b9b0fce

Please sign in to comment.