@rakeshv1108/react-native-infinite-wheel-picker
Version:
React Native component to provide infinite wheel picker selection
220 lines (205 loc) • 6.55 kB
text/typescript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
type FlatList,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import type { WheelPickerProps } from '..';
import useThrowPropsError from './useThrowPropsError';
/**
* Custom hook to handle the wheel picker logic
* @param onChangeValue - Function to handle the value change
* @param selectedIndex - The index of the selected value
* @param data - The data to be displayed in the wheel picker
* @param elementHeight - The height of each element in the wheel picker
* @param restElements - The number of elements to be displayed above and below the selected element
* @param decelerationRate - The deceleration rate of the scroll view
* @param loopCount - The number of times the data should be looped
* @returns - The necessary props for the wheel picker
*/
const useWheelPicker = ({
onChangeValue,
selectedIndex,
data,
elementHeight,
restElements,
decelerationRate,
loopCount,
infiniteScroll,
initialSelectedIndex,
}: Required<
Pick<
WheelPickerProps,
| 'loopCount'
| 'onChangeValue'
| 'selectedIndex'
| 'data'
| 'restElements'
| 'decelerationRate'
| 'elementHeight'
| 'infiniteScroll'
| 'initialSelectedIndex'
>
>) => {
/**
* Loop the data to make it infinite
* The loopCount is multiplied by 2 to ensure that the data is enough to be looped
* The loopedData is memoized to prevent re-rendering
*/
let loopCountMax = !infiniteScroll ? 1 : loopCount * 2;
const loopedData = useMemo(() => {
let looped: Array<string | number> = [];
for (let i = 0; i < loopCountMax; i++) {
looped = [...looped, ...data];
}
return looped;
}, [loopCountMax, data]);
const [arrayData, setArrayData] = useState<Array<string | number>>([
...loopedData,
]);
const flatListRef = useRef<FlatList<string | null>>(null);
const [scrollY] = useState(new Animated.Value(0));
/**
* Verify if the props are valid or not
* If not, throw an error
*/
useThrowPropsError({
selectedIndex,
arrayData,
restElements,
loopCount,
decelerationRate,
data,
elementHeight,
initialSelectedIndex,
});
/**
* Calculate the height of the container
*/
const containerHeight = (1 + restElements * 2) * elementHeight;
/**
* Adding null values to the beginning and end of the data to make the list circular
*/
const arrayElements = useMemo(() => {
const array: (string | null | number)[] = [...arrayData];
for (let i = 0; i < restElements; i++) {
array.unshift(null);
array.push(null);
}
return array;
}, [restElements, arrayData]);
/**
* Calculate the offsets for each item in the list
* The offsets are memoized to prevent re-rendering
*/
const offsets = useMemo(
() => [...Array(arrayElements.length)].map((_x, i) => i * elementHeight),
[arrayElements, elementHeight]
);
/**
* Calculate the current scroll index
*/
const currentScrollIndex = useMemo(() => {
const scrollIndex = Animated.add(
Animated.divide(scrollY, elementHeight),
restElements
);
return scrollIndex;
}, [restElements, scrollY, elementHeight]);
let timer: NodeJS.Timeout | null = null;
/**
* Handle the momentum scroll end event
* This function is called when the user stops scrolling
* It calculates the selected index based on the current scroll position
* and calls the onChangeValue function to update the selected value
* @param event - NativeSyntheticEvent<NativeScrollEvent>
*/
const handleMomentumScrollEnd = (
event: NativeSyntheticEvent<NativeScrollEvent>
) => {
const offsetY = Math.min(
elementHeight * (arrayData.length - 1),
Math.max(event.nativeEvent.contentOffset.y, 0)
);
let index = Math.floor(Math.floor(offsetY) / elementHeight);
const last = Math.floor(offsetY % elementHeight);
if (last > elementHeight / 2) index++;
if (index !== selectedIndex) {
const value = arrayData[index];
if (value) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const originalIndex = data.findIndex(
item => item === arrayData[index]
);
onChangeValue(originalIndex, value.toString());
}, 100);
}
}
};
/**
* Handle the scroll event
* This function is called when the user scrolls the wheel picker
* It checks if the user is near the end of the list
* If so, it adds more data to the list
*/
const onScrollListener = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } =
event.nativeEvent;
const detectFromItem = (data.length + 2) * elementHeight; // 2 is the number of extra elements
const isNearToEnd =
layoutMeasurement.height + contentOffset.y + elementHeight >=
contentSize.height - detectFromItem;
if (isNearToEnd && infiniteScroll) {
setArrayData([...arrayData, 1]);
}
},
[arrayData, data, elementHeight, infiniteScroll]
);
/**
* Calculate the initial scroll index
* The initial scroll index is the index of the selected value
* Here 0 is initially selected 0 index if infiniteScroll is false
*/
const initialScrollIndex = useMemo(() => {
return !infiniteScroll
? initialSelectedIndex ?? 0
: arrayData?.length / 2 + initialSelectedIndex;
}, [arrayData, initialSelectedIndex, infiniteScroll]);
/**
* Set the initial selected value
*/
useEffect(() => {
let count = 0;
if (count === 0) {
const infiniteDataIndex = data.findIndex(
item => item === arrayData[initialScrollIndex]
);
const originalIndex = !infiniteScroll
? selectedIndex ?? 0
: infiniteDataIndex;
const originalValue = !infiniteScroll
? data[initialSelectedIndex ?? 0]?.toString()
: arrayData[initialScrollIndex]?.toString();
onChangeValue(originalIndex ?? 0, originalValue ?? '');
count++;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialScrollIndex, infiniteScroll, initialSelectedIndex]);
return {
currentScrollIndex,
containerHeight,
flatListRef,
arrayElements,
scrollY,
setArrayData,
arrayData,
handleMomentumScrollEnd,
offsets,
initialScrollIndex,
onScrollListener,
};
};
export default useWheelPicker;